More browser work this time focusing on Firefox and Thunderbird.
This commit is contained in:
1 parent
a763714fb4
commit
824a4d2369
22 files changed
+1272
-8
No files matched your search
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user