More browser work this time focusing on Firefox and Thunderbird.

This commit is contained in:
Storm Dragon committed 2026-10-17 13:15:51 -04:00
1 parent a763714fb4
commit 824a4d2369
22 files changed
+1272 -8

No files matched your search

+80
View File
@@ -0,0 +1,80 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"log/slog"
"strings"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/braille"
)
func TestEditableDocumentParagraphEvents(t *testing.T) {
queue := &fakeSpeechQueue{spoken: make(chan string, 16)}
adapter := &fakeAdapter{events: make(chan atspi.Event, 16)}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, nil, nil, nil)
}()
defer func() { cancel(); <-done }()
editor := atspi.Snapshot{BusName: ":1.7", Path: "/editor", Name: "Message body", Role: "document web", Editable: true}
paragraph := atspi.Snapshot{BusName: editor.BusName, Path: "/paragraph", Role: "paragraph", Editable: true,
EditableRoot: editor.ID(), Text: "a", TextCaretKnown: true, BrailleLine: "a", BrailleCaret: 1}
adapter.events <- atspi.NewEvent(atspi.FocusEvent, editor)
adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, paragraph, "a")
// This caret must not reread the growing line after accepted typing.
adapter.events <- atspi.NewEvent(atspi.CaretEvent, paragraph)
paragraph.SelectionStart, paragraph.SelectionEnd, paragraph.SelectionText = 0, 1, "a"
adapter.events <- atspi.NewEvent(atspi.TextSelectionChangedEvent, paragraph)
adapter.events <- atspi.NewEventWithPayload(atspi.TextDeletedEvent, paragraph, "a")
subject := atspi.Snapshot{BusName: editor.BusName, Path: "/subject", Name: "Subject", Role: "entry", Editable: true}
adapter.events <- atspi.NewEvent(atspi.FocusEvent, subject)
// A delayed mutation from the old body cannot interrupt a header field.
adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, paragraph, "unfocused text")
other := paragraph
other.Path, other.EditableRoot = "/other_paragraph", atspi.NewObjectID(editor.BusName, "/other_editor")
adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, other, "other editor")
adapter.events <- atspi.NewEvent(atspi.FocusEvent, atspi.Snapshot{BusName: editor.BusName, Path: "/end", Role: "button", Name: "End"})
for _, want := range []string{"Message body, document web", "a", "a selected", "a", "Subject, entry", "End, button"} {
select {
case got := <-queue.spoken:
if got != want {
t.Fatalf("speech = %q, want %q", got, want)
}
case <-time.After(time.Second):
t.Fatalf("missing %q", want)
}
}
}
func TestEditableDocumentCaretAndSelectionBraille(t *testing.T) {
root := atspi.NewObjectID(":1.7", "/editor")
frames := make(chan braille.Frame, 2)
state := &eventLoopState{}
state.focusedID = root
handler := accessibilityEventHandler{state: state, logger: slog.New(slog.DiscardHandler), brailleOutput: frameRecorder{frames: frames}}
source := atspi.Snapshot{BusName: root.BusName, Path: "/paragraph", Role: "paragraph", Editable: true,
EditableRoot: root, Text: "body", TextCaretKnown: true, BrailleLine: "body", BrailleCaret: 2}
for _, eventType := range []string{atspi.CaretEvent, atspi.TextSelectionChangedEvent} {
handler.acceptFocusedEvent(atspi.NewEvent(eventType, source), recentInput(nil))
if state.focusedID != root || state.current.ID() != source.ID() || state.brailleSnapshot.ID() != source.ID() {
t.Fatal("paragraph presentation lost native editor focus or Braille ownership")
}
select {
case frame := <-frames:
if !strings.HasPrefix(frame.Text, "body") || frame.Cursor != 2 || len(frame.Routes) == 0 || frame.Routes[0].Path != source.Path {
t.Fatalf("paragraph Braille frame = %+v", frame)
}
default:
t.Fatal("missing paragraph Braille frame")
}
}
}
+7 -1
View File
@@ -96,6 +96,7 @@ func (handler *accessibilityEventHandler) handle(event atspi.Event) {
"prompt_message_present", event.Source.Text != "" && (event.Source.Role == "frame" || event.Source.Role == "alert") || event.WindowMessage != "",
"snapshot_duration", event.SnapshotDuration,
"editable", event.Source.Editable,
"editable_root", event.Source.EditableRoot.Path,
"autocomplete", event.Source.Autocomplete,
"autocomplete_popup", event.Source.AutocompletePopup,
"event_detail1", event.Detail1,
@@ -185,9 +186,14 @@ func (handler *accessibilityEventHandler) handle(event atspi.Event) {
activity = browser.RecentInput()
}
intent := activity.Intent
if event.Type == atspi.CaretEvent && event.Source.EditableRoot.Valid() && state.focusedID.Valid() &&
!belongsToFocusedEditor(event.Source, state.focusedID) {
logger.Debug("skipped caret outside focused editor", "role", event.Source.Role)
return
}
if isTextChangeEvent(event.Type) && event.Source.Editable &&
event.Source.Role != "terminal" && !event.Source.LiveRegion && state.focusedID.Path != "" &&
event.Source.ID() != state.focusedID && event.Source.ID() != state.managedDescendantID {
!belongsToFocusedEditor(event.Source, state.focusedID) && event.Source.ID() != state.managedDescendantID {
// Settings pages initialize editable number fields while focus
// remains in their navigation tree. These are not user edits.
logger.Debug("skipped text change outside focused editor", "type", event.Type, "role", event.Source.Role)
+6
View File
@@ -68,6 +68,12 @@ func speaksTextChanges(snapshot atspi.Snapshot) bool {
(snapshot.Editable && !snapshot.ReadOnly && snapshot.Role != "password text")
}
func belongsToFocusedEditor(source atspi.Snapshot, focus atspi.ObjectID) bool {
return focus.Path != "" && (source.ID() == focus || focus.Valid() && source.Editable && !source.ReadOnly &&
source.Role != "password text" && source.EditableRoot.Valid() && source.EditableRoot == focus &&
source.BusName == focus.BusName)
}
func isTextChangeEvent(eventType string) bool {
return eventType == atspi.TextInsertedEvent || eventType == atspi.TextDeletedEvent
}
+7
View File
@@ -95,6 +95,13 @@ func (handler *accessibilityEventHandler) acceptFocusedEvent(event atspi.Event,
logger.Debug("skipped selected state already presented by focus", "role", event.Source.Role)
return true
}
// Keep native focus on the editor, but present the paragraph which owns
// its current caret/selection. Leaving that editor immediately revokes
// this relationship; no extra mutable ownership state is retained.
if belongsToFocusedEditor(event.Source, state.focusedID) &&
(isTextChangeEvent(event.Type) || event.Type == atspi.CaretEvent || event.Type == atspi.TextSelectionChangedEvent) {
state.current = event.Source
}
if isFocusEvent(event.Type) || isManagedDescendantEvent(event.Type) ||
event.Type == atspi.AccessibleNameEvent && presentsReusedRow(event.Source) &&
event.Source.ID() == state.managedDescendantID ||
+8 -2
View File
@@ -29,7 +29,7 @@ func (handler *accessibilityEventHandler) handleSelectionAndClipboard(event atsp
return true
}
if event.Type == atspi.TextSelectionChangedEvent {
if event.Source.ID() != state.focusedID || !event.Source.Editable || event.Source.ReadOnly ||
if !belongsToFocusedEditor(event.Source, state.focusedID) || !event.Source.Editable || event.Source.ReadOnly ||
event.Source.Role == "password text" || event.Source.Autocomplete {
return true
}
@@ -37,6 +37,12 @@ func (handler *accessibilityEventHandler) handleSelectionAndClipboard(event atsp
if activity.Selecting && intent == input.LineIntent {
fragmentLimit = lineSelectionFragmentLimit
}
if event.Source.EditableRoot == state.focusedID && event.Source.ID() != state.focusedID &&
state.selection.id != event.Source.ID() {
// Native focus stays on the document. The first selection in a
// newly visited paragraph still needs its selected text spoken.
state.selection.reset(atspi.Snapshot{BusName: event.Source.BusName, Path: event.Source.Path})
}
message := state.selection.changedWithLimit(event.Source, fragmentLimit)
if message != "" {
state.pending.caret.clear()
@@ -50,7 +56,7 @@ func (handler *accessibilityEventHandler) handleSelectionAndClipboard(event atsp
return true
}
if activity.Sequence != 0 && activity.Sequence != state.lastClipboardSequence &&
event.Source.ID() == state.focusedID && event.Source.Editable && !event.Source.ReadOnly &&
belongsToFocusedEditor(event.Source, state.focusedID) && event.Source.Editable && !event.Source.ReadOnly &&
!event.Source.Autocomplete && event.Source.Role != "password text" {
message := ""
if event.Type == atspi.TextDeletedEvent && activity.Clipboard == input.CutAction {
+41 -2
View File
@@ -10,6 +10,7 @@ import (
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/input"
"git.stormux.org/storm/cthulhu/internal/platform/xorg"
"git.stormux.org/storm/cthulhu/internal/wine"
)
@@ -21,6 +22,7 @@ type windowTitleSource interface {
type pendingWindowTitle struct {
text string
previousText string
window atspi.ObjectID
epoch uint64
native xorg.WindowTitle
@@ -103,6 +105,9 @@ func (titles *windowTitleController) queue(pending pendingWindowTitle) {
if titles.pending != nil {
// Coalescing must not revive a burst interrupted since its first sample.
pending.inputSequence = titles.pending.inputSequence
// Compare the whole burst so an intervening status change cannot
// disappear behind a later title which mirrors an edit.
pending.previousText = titles.pending.previousText
}
if previous := titles.pending; previous != nil && !previous.changed && previous.epoch == pending.epoch &&
previous.window == pending.window && previous.native.Window == pending.native.Window &&
@@ -137,11 +142,13 @@ func (titles *windowTitleController) accessible(event atspi.Event) bool {
titles.lastTitle = ""
return true
}
previousTitle := titles.state.owner.title
if !titles.state.owner.changeTitle(event) {
return true
}
titles.queue(pendingWindowTitle{text: strings.TrimSpace(event.Source.Name),
window: titles.state.owner.window, epoch: titles.state.foregroundState.Epoch,
previousText: previousTitle,
window: titles.state.owner.window, epoch: titles.state.foregroundState.Epoch,
changed: true, application: titles.application})
return true
}
@@ -185,7 +192,11 @@ func (titles *windowTitleController) nativeChanged() {
latest.Title.TitleWindow == latest.Title.Window && !strings.EqualFold(latest.Title.Application, "xterm") {
changed = true
}
titles.queue(pendingWindowTitle{text: latest.Title.Title, epoch: latest.Epoch,
previousTitle := previous.Title.Title
if switched {
previousTitle = ""
}
titles.queue(pendingWindowTitle{text: latest.Title.Title, previousText: previousTitle, epoch: latest.Epoch,
native: latest.Title, changed: changed})
}
@@ -203,6 +214,31 @@ func (titles *windowTitleController) enabled() bool {
return titles.live == nil || titles.live.Load().Reading.AutomaticallyReadTitleChanges
}
// repeats_focused_edit recognizes titles whose only changed portion mirrors
// the owned entry. Use copied focus and input evidence, never application names
// or field labels. Window introductions and unrelated status changes still speak.
func (titles *windowTitleController) repeats_focused_edit(previousTitle, title string) bool {
current := titles.state.current
if previousTitle == "" || !current.Editable || current.ReadOnly || !current.Focused ||
!titles.state.focusedID.Valid() || current.ID() != titles.state.focusedID ||
titles.state.owner.focus != current.ID() || current.BusName != titles.state.owner.window.BusName {
return false
}
activity := recentInput(titles.inputSource)
if activity.Intent != input.TypingIntent && activity.Intent != input.DeleteIntent &&
activity.Clipboard != input.CutAction && activity.Clipboard != input.PasteAction {
return false
}
text := strings.TrimSpace(current.Text)
if text == "" || strings.Count(title, text) != 1 {
return false
}
start := strings.Index(title, text)
prefix, suffix := title[:start], title[start+len(text):]
return len(previousTitle) >= len(prefix)+len(suffix) &&
strings.HasPrefix(previousTitle, prefix) && strings.HasSuffix(previousTitle, suffix)
}
func (titles *windowTitleController) flush() {
pending := titles.pending
titles.clearPending()
@@ -253,6 +289,9 @@ func (titles *windowTitleController) flush() {
return
}
titles.lastTitle = pending.text
if pending.changed && titles.repeats_focused_edit(pending.previousText, pending.text) {
return
}
if pending.inputSequence != recentInput(titles.inputSource).TitleInterruptionSequence {
return
}
+83
View File
@@ -108,6 +108,89 @@ func TestScheduledTitleInterruptedBeforeSpeechQueue(t *testing.T) {
}
}
func TestAutomaticTitleDoesNotRepeatFocusedEdit(t *testing.T) {
for _, native := range []bool{false, true} {
t.Run(fmt.Sprintf("native=%v", native), func(t *testing.T) {
titles, queue, changed := titleControllerFixture(t)
tracker := &input.IntentTracker{}
titles.inputSource = tracker
entry := atspi.Snapshot{BusName: changed.Source.BusName, Path: "/entry", Role: "entry", Editable: true, Focused: true}
titles.state.current = entry
titles.state.focusedID, titles.state.owner.focus = entry.ID(), entry.ID()
titles.state.owner.title, titles.lastTitle = "Compose: (empty) - Mail", "Compose: (empty) - Mail"
source := &titleProbeSource{title: atspi.ForegroundTitle{Epoch: 1, Title: xorg.WindowTitle{Window: 20, TitleWindow: 20, Title: titles.lastTitle}}}
if native {
titles.source, titles.foreground, titles.lastNative = source, source, source.title
}
change := func(text string) {
if native {
source.title.Title.Title = text
titles.nativeChanged()
} else {
changed.Source.Name = text
titles.accessible(changed)
}
titles.flush()
}
for _, step := range []struct{ key, text string }{{"D", "D"}, {"r", "Dr"}, {"BackSpace", "D"}} {
tracker.Observe(input.NewKeyEvent(0, true, step.key))
titles.state.current.Text = step.text
change("Compose: " + step.text + " - Mail")
}
if len(queue.requests) != 0 {
t.Fatalf("titles repeated the edited text: %+v", queue.requests)
}
// The entry still owns focus and input is still recent. A changed
// status outside the edited value must nevertheless be announced.
change("Compose: D - Mail (saved)")
if len(queue.requests) != 1 || queue.requests[0].Text != "Compose: D - Mail (saved)" {
t.Fatalf("unrelated status title = %+v", queue.requests)
}
})
}
}
func TestAutomaticTitleEditFilterRequiresOwnedEdit(t *testing.T) {
for _, name := range []string{"no-input", "navigation", "read-only", "unfocused", "other-focus", "other-window", "empty", "ambiguous", "introduction"} {
t.Run(name, func(t *testing.T) {
titles, queue, changed := titleControllerFixture(t)
tracker := &input.IntentTracker{}
titles.inputSource = tracker
tracker.Observe(input.NewKeyEvent(0, true, "r"))
titles.state.current = atspi.Snapshot{BusName: changed.Source.BusName, Path: "/entry", Role: "entry", Text: "Draft", Editable: true, Focused: true}
titles.state.focusedID, titles.state.owner.focus = titles.state.current.ID(), titles.state.current.ID()
titles.state.owner.title, titles.lastTitle = "Compose: Draf - Mail", "Compose: Draf - Mail"
changed.Source.Name = "Compose: Draft - Mail"
switch name {
case "no-input":
titles.inputSource = nil
case "navigation":
tracker.Observe(input.NewKeyEvent(0, true, "Left"))
case "read-only":
titles.state.current.ReadOnly = true
case "unfocused":
titles.state.current.Focused = false
case "other-focus":
titles.state.owner.focus.Path = "/other"
case "other-window":
titles.state.current.BusName = ":1.99"
titles.state.focusedID, titles.state.owner.focus = titles.state.current.ID(), titles.state.current.ID()
case "empty":
titles.state.current.Text = ""
case "ambiguous":
changed.Source.Name = "Compose: Draft - Draft"
case "introduction":
titles.state.owner.title, titles.lastTitle = "", ""
}
titles.accessible(changed)
titles.flush()
if len(queue.requests) != 1 || queue.requests[0].Text != changed.Source.Name {
t.Fatalf("title without matching edit = %+v", queue.requests)
}
})
}
}
func TestScheduledNativeIntroductionPreservesSwitchAndRepeatInput(t *testing.T) {
titles, queue, _ := titleControllerFixture(t)
titles.state.owner.clear()
+3
View File
@@ -852,7 +852,10 @@ gchar *cthulhu_capture_caret_unit(AtspiAccessible *source, AtspiTextGranularity
static void cthulhu_capture_document_metrics(AtspiAccessible *source, cthulhu_snapshot *snapshot) {
const gchar *role = snapshot->role;
/* Gecko can expose heading/link text directly without static children. */
if (g_strcmp0(role, "paragraph") != 0 &&
g_strcmp0(role, "heading") != 0 &&
g_strcmp0(role, "link") != 0 &&
g_strcmp0(role, "list item") != 0 &&
g_strcmp0(role, "math") != 0 &&
g_strcmp0(role, "static") != 0 &&
@@ -110,6 +110,13 @@ 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 snapshot.Editable && (eventType == CaretEvent || eventType == TextInsertedEvent ||
eventType == TextDeletedEvent || eventType == TextSelectionChangedEvent) {
if editor := C.cthulhu_editable_document(target); editor != nil {
snapshot.EditableRoot = nativeObjectID(editor)
C.g_object_unref(C.gpointer(editor))
}
}
snapshot.ApplicationProcess = adapter.applicationProcess(target)
if snapshot.Role == "slider" ||
snapshot.Role == "combo box" && (snapshot.Name == "Key echo speech synthesizer" || snapshot.Name == "Key echo person") {
+27
View File
@@ -544,6 +544,33 @@ AtspiAccessible *cthulhu_window_ancestor(AtspiAccessible *source) {
return NULL;
}
// Editable documents can retain native focus while their paragraphs emit
// typing/caret/selection events. Stop at the nearest document boundary so an
// unrelated outer editor cannot claim events from a nested read-only document.
AtspiAccessible *cthulhu_editable_document(AtspiAccessible *source) {
AtspiAccessible *current = source != NULL ? g_object_ref(source) : NULL;
for (gint depth = 0; current != NULL && depth < 64; depth++) {
AtspiRole role = atspi_accessible_get_role(current, NULL);
if (role == ATSPI_ROLE_DOCUMENT_WEB || role == ATSPI_ROLE_DOCUMENT_FRAME) {
AtspiStateSet *states = atspi_accessible_get_state_set(current);
gboolean editable = states != NULL &&
atspi_state_set_contains(states, ATSPI_STATE_EDITABLE) &&
!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 (editable) return current;
g_object_unref(current);
return NULL;
}
if (role == ATSPI_ROLE_FRAME || role == ATSPI_ROLE_WINDOW || role == ATSPI_ROLE_APPLICATION) break;
AtspiAccessible *parent = atspi_accessible_get_parent(current, NULL);
g_object_unref(current);
current = parent;
}
if (current != NULL) g_object_unref(current);
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) {
+95 -3
View File
@@ -84,10 +84,9 @@ func (adapter *Adapter) matchForeground(window *C.AtspiAccessible, id ObjectID,
}
matched := foregroundMenuPopupMatches(state, adapter.foregroundWindow, id, pid,
focusedMenuItem || C.cthulhu_active_menu_window(window) != 0)
if !matched && check != nil {
check.Reason = "different accessible window from correlated foreground"
if matched {
return true
}
return matched
}
// Same-process maximized windows can have identical bounds. As in
// Python canBeActiveWindow(clearCache=True), only correlate a freshly
@@ -144,11 +143,104 @@ func (adapter *Adapter) matchForeground(window *C.AtspiAccessible, id ObjectID,
}
return false
}
// Mozilla can replace its GTK application root with a Gecko root without
// changing the X11 client. Only inspect its window list after fresh state
// and geometry match. Existing siblings cannot retire the previous owner.
if adapter.foregroundWindow.Valid() && !adapter.replacesForegroundWindow(window, id, state.PID, pid) {
if check != nil {
check.Reason = "different accessible window from correlated foreground"
}
return false
}
if adapter.foregroundWindow.Valid() {
adapter.forgetNativeWindowFocus(adapter.foregroundWindow)
adapter.forgetWindowDocuments(adapter.foregroundWindow)
}
adapter.foregroundWindow = id
adapter.foregroundRecovery = 0
return true
}
func (adapter *Adapter) replacesForegroundWindow(window *C.AtspiAccessible, id ObjectID, foregroundPID, pid int) bool {
if foregroundPID <= 0 || pid != foregroundPID || id.BusName != adapter.foregroundWindow.BusName {
return false
}
application := C.atspi_accessible_get_parent(window, nil)
if application == nil {
return false
}
defer C.g_object_unref(C.gpointer(application))
if C.atspi_accessible_get_role(application, nil) != C.ATSPI_ROLE_APPLICATION {
return false
}
// A retired GTK bridge can still answer queries after Gecko registers
// its replacement application. Do not let that detached root reclaim
// the client. GetApplication itself can use libatspi's old root cache.
desktop := C.atspi_get_desktop(0)
if desktop == nil {
return false
}
defer C.g_object_unref(C.gpointer(desktop))
C.atspi_accessible_clear_cache_single(desktop)
var queryError *C.GError
applications := int(C.atspi_accessible_get_child_count(desktop, &queryError))
if queryError != nil {
C.g_error_free(queryError)
return false
}
if applications <= 0 || applications > 128 {
return false
}
registered := false
for index := 0; index < applications; index++ {
child := C.atspi_accessible_get_child_at_index(desktop, C.gint(index), &queryError)
if queryError != nil {
C.g_error_free(queryError)
}
if child == nil {
return false
}
matches := nativeObjectID(child) == nativeObjectID(application)
C.g_object_unref(C.gpointer(child))
if queryError != nil {
return false
}
if matches {
registered = true
break
}
}
if !registered {
return false
}
C.atspi_accessible_clear_cache_single(application)
count := int(C.atspi_accessible_get_child_count(application, &queryError))
if queryError != nil {
C.g_error_free(queryError)
return false
}
if count <= 0 || count > 128 {
return false
}
windows := make([]ObjectID, 0, count)
for index := 0; index < count; index++ {
child := C.atspi_accessible_get_child_at_index(application, C.gint(index), &queryError)
if queryError != nil {
C.g_error_free(queryError)
}
if child == nil {
return false
}
childID := nativeObjectID(child)
C.g_object_unref(C.gpointer(child))
if queryError != nil || !childID.Valid() {
return false
}
windows = append(windows, childID)
}
return foregroundWindowReplaced(adapter.foregroundWindow, id, windows)
}
// Recovery runs only at foreground transitions, off the event consumer. It
// handles returning from an empty workspace when a toolkit retains focus and
// emits no new focused-child event. Searches and retries are bounded.
+1
View File
@@ -120,6 +120,7 @@ const gchar *cthulhu_accessible_path(AtspiAccessible *source);
const gchar *cthulhu_accessible_bus_name(AtspiAccessible *source);
AtspiAccessible *cthulhu_window_ancestor(AtspiAccessible *source);
AtspiAccessible *cthulhu_document_ancestor(AtspiAccessible *source);
AtspiAccessible *cthulhu_editable_document(AtspiAccessible *source);
AtspiEventListenerCB cthulhu_event_callback_pointer(void);
gboolean cthulhu_request_event_loop_quit(void);
gboolean cthulhu_request_tree_capture(gpointer userData);
@@ -158,6 +158,26 @@ func (adapter *Adapter) rememberNativeFocus(source *C.AtspiAccessible, id Object
adapter.nativeFocus = append(adapter.nativeFocus, nativeFocusReference{id: id, source: owned})
}
// An unregistered bridge window must not remain a focused chrome candidate
// after its replacement has acquired the same X11 client. Other windows keep
// their retained focus references. This runs only on the GLib owner.
func (adapter *Adapter) forgetNativeWindowFocus(windowID ObjectID) {
retained := adapter.nativeFocus[:0]
for _, item := range adapter.nativeFocus {
window := C.cthulhu_window_ancestor(item.source)
retired := nativeObjectID(window) == windowID
if window != nil {
C.g_object_unref(C.gpointer(window))
}
if retired {
C.g_object_unref(C.gpointer(item.source))
} else {
retained = append(retained, item)
}
}
adapter.nativeFocus = retained
}
// Keep an exact document-to-frame association across Chromium tab switches.
// Some returned tabs temporarily lose their AT-SPI parent chain, although
// their document identity and the already correlated frame remain live.
+6
View File
@@ -149,6 +149,12 @@ chromeFocus:
if err != nil {
t.Fatal(err)
}
for _, name := range []string{"Firefox baseline heading", "Firefox baseline link"} {
item, err := page.Resolve(findNamedReference(t, page, name))
if err != nil || item.CharacterCount != len([]rune(name)) {
t.Errorf("Firefox document text metadata for %q: characters=%d err=%v", name, item.CharacterCount, err)
}
}
for _, node := range captured.Nodes {
if node.Snapshot.Role == "paragraph" {
paragraph = node.Reference
+9
View File
@@ -6,10 +6,19 @@ package atspi
import (
"math"
"slices"
"git.stormux.org/storm/cthulhu/internal/platform/xorg"
)
// The native caller supplies a complete, fresh, bounded application window
// list, and separately checks the candidate's process, state and geometry.
func foregroundWindowReplaced(previous, candidate ObjectID, windows []ObjectID) bool {
return previous.Valid() && candidate.Valid() && previous != candidate &&
previous.BusName == candidate.BusName && slices.Contains(windows, candidate) &&
!slices.Contains(windows, previous)
}
// Epoch changes with the foreground client, including departure to an empty
// workspace. Moving/resizing the same client does not reset reading ownership.
type ForegroundState struct {
+26
View File
@@ -72,3 +72,29 @@ func TestForegroundMenuPopupCorrelation(t *testing.T) {
})
}
}
func TestForegroundWindowReplacement(t *testing.T) {
previous := NewObjectID(":1.40", "/gtk_frame")
candidate := NewObjectID(":1.40", "/gecko_frame")
sibling := NewObjectID(":1.40", "/other_window")
for _, test := range []struct {
name string
previous, candidate ObjectID
windows []ObjectID
want bool
}{
{"replaced application root", previous, candidate, []ObjectID{candidate, sibling}, true},
{"existing sibling cannot take ownership", previous, candidate, []ObjectID{previous, candidate}, false},
{"unregistered candidate", previous, candidate, []ObjectID{sibling}, false},
{"missing window list", previous, candidate, nil, false},
{"other provider", previous, NewObjectID(":1.41", "/frame"), []ObjectID{NewObjectID(":1.41", "/frame")}, false},
{"no previous owner", ObjectID{}, candidate, []ObjectID{candidate}, false},
{"unchanged owner", candidate, candidate, []ObjectID{candidate}, false},
} {
t.Run(test.name, func(t *testing.T) {
if got := foregroundWindowReplaced(test.previous, test.candidate, test.windows); got != test.want {
t.Fatalf("replacement = %v, want %v", got, test.want)
}
})
}
}
+3
View File
@@ -86,6 +86,9 @@ type Snapshot struct {
Checked bool
Defunct bool
Editable bool
// EditableRoot identifies the nearest editable web document, copied while
// capturing native events. Paragraph events can belong to its native focus.
EditableRoot ObjectID
Expandable bool
Expanded bool
Indeterminate bool