Element list keys added.

This commit is contained in:
Storm Dragon committed 2026-10-17 01:01:51 -04:00
1 parent 25a1338222
commit 37fdd4e114
33 files changed
+1530 -15

No files matched your search

+99
View File
@@ -0,0 +1,99 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"fmt"
"strconv"
"strings"
"unicode"
"git.stormux.org/storm/cthulhu/internal/input"
"git.stormux.org/storm/cthulhu/internal/interaction"
"git.stormux.org/storm/cthulhu/internal/review"
)
type documentListSource interface {
ListDocument(context.Context, review.DocumentTarget, int) (review.DocumentListSnapshot, error)
SelectDocumentList(review.DocumentListSnapshot, int) error
}
func (ui *internalUI) browserListMessage(message string) {
if ui.gate.Locked() || ui.gate.Suspended() {
return
}
ui.speech.Interrupt()
ui.speech.Enqueue(speechRequest{Text: message, Role: "system", Cause: "browser-list"})
if ui.braille != nil {
ui.braille.EnqueueMessage(message)
}
}
func documentListTarget(binding input.DocumentListBinding) review.DocumentTarget {
switch binding.Command {
case input.DocumentListAnnotations:
return review.DocumentAnnotation
case input.DocumentListInternalFrames:
return review.DocumentInternalFrame
case input.DocumentListMath:
return review.DocumentMath
}
target, _, _ := documentReviewCommand(binding.Next)
return target
}
func (ui *internalUI) browserList(ctx context.Context, binding input.DocumentListBinding) (*interaction.Session, review.DocumentListSnapshot, error) {
if ui.documents == nil {
return nil, review.DocumentListSnapshot{}, review.ErrNoCurrent
}
list, err := ui.documents.ListDocument(ctx, documentListTarget(binding), binding.Level)
if err != nil {
return nil, list, err
}
if len(list.Entries) == 0 {
return nil, list, nil
}
items := make([]interaction.Item, 0, len(list.Entries))
for index, position := range list.Entries {
snapshot := position.Snapshot
label := position.Label
if label == "" {
label = snapshot.Name
}
if label == "" && snapshot.Role != "password text" {
label = snapshot.Text
}
if label == "" {
label = snapshot.Role
}
label = strings.Join(strings.FieldsFunc(label, func(r rune) bool { return unicode.IsControl(r) || unicode.IsSpace(r) }), " ")
// Keep the existing form label bound without splitting UTF-8.
labelRunes := []rune(label)
if len(labelRunes) > 225 {
label = string(labelRunes[:225])
}
if label == "" {
label = snapshot.Role
}
if snapshot.HeadingLevel > 0 {
label += fmt.Sprintf(", heading level %d", snapshot.HeadingLevel)
}
if snapshot.Role == "check box" || snapshot.Role == "radio button" || snapshot.Role == "toggle button" {
if snapshot.Checked {
label += ", checked"
} else {
label += ", not checked"
}
}
items = append(items, interaction.Item{ID: strconv.Itoa(index), Label: label})
}
title := fmt.Sprintf("%s: %d items found", binding.Title, len(items))
if len(items) == 1 {
title = binding.Title + ": 1 item found"
}
session, err := interaction.NewNavigationSession(title, items, list.Selected)
return session, list, err
}
+135
View File
@@ -0,0 +1,135 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"os"
"os/exec"
"strings"
"testing"
"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/review"
)
func TestNativeBrowserLists(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_BROWSER") != "1" {
t.Skip("requires private browser list fixture")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
adapter := atspi.New()
if err := adapter.Start(ctx); err != nil {
t.Fatal(err)
}
defer adapter.Stop(context.Background())
key := func(chord string) {
t.Helper()
if out, err := exec.CommandContext(ctx, "xdotool", "key", "--clearmodifiers", chord).CombinedOutput(); err != nil {
t.Fatalf("keys: %v %s", err, out)
}
}
var focus atspi.ObjectID
for attempt := 0; attempt < 25 && !focus.Valid(); attempt++ {
key("Tab")
timer := time.NewTimer(150 * time.Millisecond)
loop:
for {
select {
case event := <-adapter.Events():
if isFocusEvent(event.Type) && event.Source.Name == "Browser foundation" {
focus = event.Source.ID()
break loop
}
case <-timer.C:
break loop
case <-ctx.Done():
t.Fatal(ctx.Err())
}
}
timer.Stop()
}
if !focus.Valid() {
t.Fatal("fixture heading focus missing")
}
worker, _ := review.NewWorker(adapter, 2*time.Second)
if err := worker.Start(ctx); err != nil {
t.Fatal(err)
}
defer worker.Stop(context.Background())
worker.SyncFocus(focus)
keys := xorg.NewCommands([]string{"Insert"}, input.DesktopKeyboardLayout)
if err := keys.Start(ctx); err != nil {
t.Fatal(err)
}
defer keys.Stop(context.Background())
keys.SetBrowseNavigation(true)
for !keys.BrowseNavigationEnabled() {
if ctx.Err() != nil {
t.Fatal(ctx.Err())
}
time.Sleep(5 * time.Millisecond)
}
output := &ocrTestOutput{speech: make(chan speechRequest, 32)}
ui := &internalUI{keys: keys, documents: worker, speech: output, gate: newFocusGate(keys), events: make(chan input.CommandEvent, 32), done: make(chan struct{})}
go ui.run(ctx)
done := make(chan struct{})
go func() {
defer close(done)
for {
select {
case <-ctx.Done():
return
case event := <-keys.Commands():
ui.Handle(event)
case <-adapter.Events(): // Keep the native event queue draining.
}
}
}()
defer func() { cancel(); <-ui.done; <-done }()
wait := func(want string) {
t.Helper()
select {
case message := <-output.speech:
if !strings.HasPrefix(message.Text, want) {
t.Fatalf("speech %q, want prefix %q", message.Text, want)
}
case <-ctx.Done():
t.Fatalf("missing %q: %v", want, ctx.Err())
}
}
key("alt+shift+k")
wait("Links: 302 items found. First link")
key("Down")
wait("Second link")
key("Return")
select {
case result := <-worker.Results():
if result.Err != nil || !result.Moved || result.DocumentPosition.Snapshot.Name != "Second link" {
t.Fatalf("list selection: %+v", result)
}
case <-ctx.Done():
t.Fatal("selection did not reach review worker")
}
key("alt+shift+k")
wait("Links: 302 items found. Second link")
key("Escape")
// The next shortcut follows Escape in the native input stream.
key("alt+shift+a")
wait("Clickables: 1 item found. Custom clickable")
key("Escape")
key("alt+shift+2")
wait("Headings at level 2: 1 item found. Second heading")
key("Escape")
key("alt+shift+g")
wait("Images: 0 items found")
if keys.InternalUISession() != 0 {
t.Fatal("empty list retained keyboard capture")
}
}
+97
View File
@@ -0,0 +1,97 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"fmt"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/input"
"git.stormux.org/storm/cthulhu/internal/review"
)
type listTestSource struct {
keys *uiTestKeys
selected chan int
count int
}
func (source *listTestSource) ListDocument(context.Context, review.DocumentTarget, int) (review.DocumentListSnapshot, error) {
list := review.DocumentListSnapshot{Selected: source.count - 1}
for index := 0; index < source.count; index++ {
list.Entries = append(list.Entries, review.DocumentListEntry{DocumentPosition: review.DocumentPosition{Snapshot: atspi.Snapshot{Name: fmt.Sprintf("Example %03d", index), Role: "link"}}})
}
return list, nil
}
func (source *listTestSource) SelectDocumentList(_ review.DocumentListSnapshot, index int) error {
if source.keys.InternalUISession() != 0 {
index = -1
}
source.selected <- index
return nil
}
func TestBrowserListNavigationSelectionAndCancellation(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
keys := &uiTestKeys{}
source := &listTestSource{keys: keys, selected: make(chan int, 1), count: 300}
output := &ocrTestOutput{speech: make(chan speechRequest, 16)}
ui := &internalUI{keys: keys, documents: source, speech: output, gate: &focusGate{}, events: make(chan input.CommandEvent, 16), done: make(chan struct{})}
go ui.run(ctx)
defer func() { cancel(); <-ui.done }()
wait := func(want string) {
t.Helper()
select {
case message := <-output.speech:
if message.Text != want {
t.Fatalf("got %q want %q", message.Text, want)
}
case <-ctx.Done():
t.Fatal(ctx.Err())
}
}
send := func(symbol uint32) {
ui.Handle(input.CommandEvent{Command: input.InternalUIKey, UISession: 1, Chord: input.CommandChord{Symbol: symbol}})
}
ui.Handle(input.CommandEvent{Command: input.DocumentListLinks})
wait("Links: 300 items found. Example 299")
send(0xff50)
wait("Example 000")
send(0xff54)
wait("Example 001")
send(0xff0d)
select {
case index := <-source.selected:
if index != 1 {
t.Fatalf("selected %d or keyboard capture retained", index)
}
case <-ctx.Done():
t.Fatal("selection did not finish")
}
ui.Handle(input.CommandEvent{Command: input.DocumentListLinks})
wait("Links: 300 items found. Example 299")
send(0xff1b)
// A second request is processed after Escape, establishing cancellation
// without a timing assumption or a selection callback.
ui.Handle(input.CommandEvent{Command: input.DocumentListLinks})
wait("Links: 300 items found. Example 299")
select {
case <-source.selected:
t.Fatal("Escape selected an item")
default:
}
send(0xff1b)
}
func TestBrowserListCatalogMapsAllOrcaListTypes(t *testing.T) {
for _, binding := range input.DocumentListBindings() {
if documentListTarget(binding) == 0 {
t.Fatalf("unhandled %s", binding.Command)
}
}
}
+200
View File
@@ -0,0 +1,200 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"log/slog"
"os"
"os/exec"
"strings"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
)
type menuRecordingAdapter struct {
*atspi.Adapter
events chan atspi.Event
}
func (adapter *menuRecordingAdapter) Events() <-chan atspi.Event { return adapter.events }
func TestNativeBrowserMenuControls(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_BROWSER") != "1" {
t.Skip("requires a private browser session")
}
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
native := atspi.New()
if err := native.Start(ctx); err != nil {
t.Fatal(err)
}
defer native.Stop(context.Background())
adapter := &menuRecordingAdapter{Adapter: native, events: make(chan atspi.Event, 256)}
observed := make(chan atspi.Event, 256)
pumpDone := make(chan struct{})
go func() {
defer close(pumpDone)
for {
select {
case event := <-native.Events():
select {
case observed <- event:
case <-ctx.Done():
return
}
select {
case adapter.events <- event:
case <-ctx.Done():
return
}
case <-ctx.Done():
return
}
}
}()
queue := &autocompleteRequestQueue{requests: make(chan speechRequest, 256)}
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; <-pumpDone }()
keys := func(chord string) {
t.Helper()
if out, err := exec.CommandContext(ctx, "xdotool", "key", "--clearmodifiers", chord).CombinedOutput(); err != nil {
t.Fatalf("keys: %v %s", err, out)
}
}
waitEvent := func(match func(atspi.Event) bool) atspi.Event {
t.Helper()
for {
select {
case event := <-observed:
if match(event) {
t.Logf("event %s (native %s): %s %q, origin %s", event.Type, event.NativeType, event.Source.Role, event.Source.Name, event.OriginPath)
return event
}
case <-ctx.Done():
t.Fatal("missing menu event: ", ctx.Err())
return atspi.Event{}
}
}
}
waitSpeech := func(name string) {
t.Helper()
if name == "" {
t.Fatal("menu control has no accessible name")
}
timer := time.NewTimer(time.Second)
defer timer.Stop()
for {
select {
case request := <-queue.requests:
if strings.Contains(request.Text, name) {
t.Logf("speech: %q", request.Text)
return
}
case <-timer.C:
t.Fatalf("missing menu speech %q", name)
case <-ctx.Done():
t.Fatal(ctx.Err())
}
}
}
settle := func() {
t.Helper()
timer := time.NewTimer(100 * time.Millisecond)
defer timer.Stop()
for {
select {
case <-observed:
timer.Reset(100 * time.Millisecond)
case request := <-queue.requests:
if request.Cause == atspi.SelectionChangedEvent || request.Cause == atspi.ActiveDescendantEvent {
t.Fatalf("duplicate menu speech without another key: %q", request.Text)
}
case <-timer.C:
return
case <-ctx.Done():
t.Fatal(ctx.Err())
}
}
}
keys("F10")
waitEvent(func(e atspi.Event) bool { return isFocusGainEvent(e) && strings.Contains(e.Source.Name, "Brave") })
keys("Down")
waitEvent(func(e atspi.Event) bool { return e.Type == atspi.WindowActivatedEvent && e.Source.Role == "menu" })
// The app menu wraps; find the row by its real selection rather than
// relying on a fixed number of entries or the current browser profile.
for steps := 0; ; steps++ {
if steps >= 40 {
t.Fatal("Sidebar row absent from app menu")
}
keys("Down")
event := waitEvent(func(e atspi.Event) bool { return e.Type == atspi.SelectionChangedEvent && e.Source.Role == "menu item" })
waitSpeech(event.Source.Name)
if event.Source.Name == "Sidebar" {
break
}
settle()
}
inline := func(role, rowName string) atspi.Event {
return waitEvent(func(e atspi.Event) bool {
return (e.Type == atspi.SelectedStateEvent || e.NativeType == atspi.SelectedStateEvent) &&
e.Detail1 == 1 && e.Source.Role == role && e.Source.Name != rowName
})
}
first := inline("button", "Sidebar")
waitSpeech(first.Source.Name)
settle()
for _, chord := range []string{"Down", "Down", "Up", "Up", "Down", "Up"} {
keys(chord)
event := inline("button", "Sidebar")
if event.Type != atspi.ActiveDescendantEvent || event.OriginPath == event.Source.Path {
t.Fatalf("inline control lacks menu ownership: %+v", event)
}
waitSpeech(event.Source.Name)
settle()
}
for range 2 {
keys("Down")
event := inline("button", "Sidebar")
waitSpeech(event.Source.Name)
settle()
}
// Continue through the normal entries to the second compound row.
// Its inner controls use menu-item roles rather than button roles.
for steps := 0; ; steps++ {
if steps >= 25 {
t.Fatal("Zoom row absent from app menu")
}
keys("Down")
event := waitEvent(func(e atspi.Event) bool { return e.Type == atspi.SelectionChangedEvent && e.Source.Role == "menu item" })
waitSpeech(event.Source.Name)
if event.Source.Name == "Zoom" {
break
}
settle()
}
zoom := inline("menu item", "Zoom")
waitSpeech(zoom.Source.Name)
settle()
for _, chord := range []string{"Down", "Down", "Up", "Up", "Down", "Up"} {
keys(chord)
event := inline("menu item", "Zoom")
if event.Type != atspi.ActiveDescendantEvent || event.OriginPath != zoom.OriginPath {
t.Fatalf("Zoom control lacks the same menu owner: %+v", event)
}
waitSpeech(event.Source.Name)
settle()
}
keys("Escape")
waitEvent(func(e atspi.Event) bool { return e.Type == atspi.WindowActivatedEvent && e.Source.Role != "menu" })
keys("ctrl+l")
waitSpeech("Address and search bar")
}
+46 -4
View File
@@ -9,6 +9,7 @@ import (
"errors"
"io"
"os/exec"
"strconv"
"strings"
"time"
@@ -50,9 +51,10 @@ type internalUIRequest struct {
// keeps AT-SPI/clipboard latency off the native input and command consumers.
// Core clients can Open a form and receive its submitted values/cancellation.
type internalUI struct {
keys internalUIKeys
actions accessibleActionSource
interest interface {
documents documentListSource
keys internalUIKeys
actions accessibleActionSource
interest interface {
ObjectOfInterest(context.Context) (review.Interest, error)
}
speech speechEnqueuer
@@ -104,7 +106,8 @@ func (ui *internalUI) Notice(ctx context.Context, value tentaclewire.Presentatio
}
}
func (ui *internalUI) Handle(event input.CommandEvent) bool {
if ui == nil || event.Command != input.ShowActions && event.Command != input.InternalUIKey && event.Command != input.InternalUIDismiss {
_, browserList := input.LookupDocumentList(event.Command)
if ui == nil || !browserList && event.Command != input.ShowActions && event.Command != input.InternalUIKey && event.Command != input.InternalUIDismiss {
return false
}
select {
@@ -137,6 +140,7 @@ func (ui *internalUI) run(ctx context.Context) {
var session *interaction.Session
var sessionID uint64
var target atspi.ActionTarget
var documentList review.DocumentListSnapshot
var result chan internalUIResult
var requestDone <-chan struct{}
finish := func(reply internalUIResult) error {
@@ -163,6 +167,7 @@ func (ui *internalUI) run(ctx context.Context) {
}
session, sessionID, result, requestDone = nil, 0, nil, nil
target = atspi.ActionTarget{}
documentList = review.DocumentListSnapshot{}
return closeErr
}
defer func() { finish(internalUIResult{Cancelled: true}) }()
@@ -201,6 +206,33 @@ func (ui *internalUI) run(ctx context.Context) {
ui.present(session.Opening())
request.ready <- nil
case event := <-ui.events:
if binding, ok := input.LookupDocumentList(event.Command); ok {
if sessionID != 0 {
continue
}
lookupCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
var err error
sessionID, err = ui.keys.BeginInternalUI(lookupCtx)
if err == nil {
session, documentList, err = ui.browserList(lookupCtx, binding)
}
cancel()
if sessionID == 0 || ui.keys.InternalUISession() != sessionID {
finish(internalUIResult{Cancelled: true})
continue
}
if err != nil || session == nil {
message := binding.Title + ": 0 items found"
if err != nil {
message = "Browser list unavailable"
}
finish(internalUIResult{Err: err})
ui.browserListMessage(message)
continue
}
ui.present(session.Opening())
continue
}
if event.Command == input.ShowActions {
if sessionID != 0 {
finish(internalUIResult{Cancelled: true})
@@ -303,7 +335,17 @@ func (ui *internalUI) run(ctx context.Context) {
if effect.Activated != "" {
values := session.Values()
actionTarget := target
selectedList := documentList
closeErr := finish(internalUIResult{Activated: effect.Activated, Values: values})
if len(selectedList.Entries) > 0 && closeErr == nil && !ui.gate.Locked() && !ui.gate.Suspended() {
index, err := strconv.Atoi(effect.Activated)
if err == nil {
err = ui.documents.SelectDocumentList(selectedList, index)
}
if err != nil {
ui.browserListMessage("Browser item unavailable")
}
}
if actionTarget.Object.Valid() && closeErr == nil {
actionCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
err := ui.actions.PerformAccessibleAction(actionCtx, actionTarget, effect.Activated)
+3
View File
@@ -150,6 +150,9 @@ func spokenLearnKey(event input.CommandEvent) string {
}
func learnCommandDescription(command input.Command) string {
if _, ok := input.LookupDocumentList(command); ok {
return input.CommandLabel(command)
}
switch command {
case input.ShowActions:
return "Show the actions available for the current object"
+2 -1
View File
@@ -141,7 +141,8 @@ func (worker commandRuntime) run(ctx context.Context) error {
if worker.internalUI.Handle(event) {
continue
}
if worker.internalUI != nil && (command == input.ShowActions || command == input.InternalUIKey || command == input.InternalUIDismiss) {
_, browserList := input.LookupDocumentList(command)
if worker.internalUI != nil && (browserList || command == input.ShowActions || command == input.InternalUIKey || command == input.InternalUIDismiss) {
stopRun()
return errors.New("internal UI event queue overflow")
}
+1 -1
View File
@@ -65,7 +65,7 @@ func startRuntimeWorkers(ctx context.Context, command commandRuntime, inputListe
clipboardDone: clipboardDone,
}
if keys, ok := command.commands.(internalUIKeys); ok {
ui := &internalUI{notices: make(chan internalUINotice, 4), keys: keys, actions: command.actionSource, interest: command.objectReview, speech: speech, braille: braille, gate: command.focusGate, events: make(chan input.CommandEvent, 32), requests: make(chan internalUIRequest, 1), done: make(chan struct{})}
ui := &internalUI{notices: make(chan internalUINotice, 4), keys: keys, documents: command.objectReview, actions: command.actionSource, interest: command.objectReview, speech: speech, braille: braille, gate: command.focusGate, events: make(chan input.CommandEvent, 32), requests: make(chan internalUIRequest, 1), done: make(chan struct{})}
command.internalUI = ui
if command.tentacles != nil {
command.tentacles.forms = ui
@@ -462,6 +462,20 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) {
}
payload := C.cthulhu_event_payload(event)
snapshot, popupFor := adapter.captureNativeEventSnapshot(event, target, eventType, originID)
var inlineMenuID ObjectID
if eventType == SelectedStateEvent && C.cthulhu_event_detail1(event) == 1 && !snapshot.Defunct &&
(snapshot.Role == "button" || snapshot.Role == "push button" || snapshot.Role == "toggle button" ||
snapshot.Role == "menu item" || snapshot.Role == "check menu item" || snapshot.Role == "radio menu item") {
if menu := C.cthulhu_inline_menu_container(target); menu != nil {
inlineMenuID = nativeObjectID(menu)
C.g_object_unref(C.gpointer(menu))
if inlineMenuID.Valid() && inlineMenuID.BusName == snapshot.BusName {
// The event's detail is authoritative even when libatspi still
// caches the previous SELECTED state on the first visit.
eventType, snapshot.Selected = ActiveDescendantEvent, true
}
}
}
// Chromium can drop ACTIVE from its visible main frame while a tiny
// same-process sibling claims it. On X11, the correlated foreground
// window is the stronger ownership check performed below. Keep the
@@ -525,6 +539,9 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) {
clear(adapter.navigationEcho)
}
focusEvent.OriginPath = sanitizeSnapshotField(C.GoString(C.cthulhu_accessible_path(event.source)))
if eventType == ActiveDescendantEvent && inlineMenuID.Valid() {
focusEvent.OriginPath = inlineMenuID.Path
}
C.g_free(C.gpointer(payload))
if ownedTarget {
C.g_object_unref(C.gpointer(target))
+43
View File
@@ -454,6 +454,49 @@ AtspiAccessible *cthulhu_popup_menu_target(AtspiAccessible *window) {
return menu;
}
// Compound native menu rows can report selection on an inner control without
// changing the menu's selected child. Return its menu only when a menu-item
// ancestor precedes that menu, and the chain reaches a native window without
// entering document content. The caller owns the returned reference.
AtspiAccessible *cthulhu_inline_menu_container(AtspiAccessible *source) {
AtspiAccessible *current = atspi_accessible_get_parent(source, NULL);
AtspiAccessible *menu = NULL;
gboolean inRow = FALSE;
for (int depth = 0; current != NULL && depth < 32; depth++) {
GError *error = NULL;
AtspiRole role = atspi_accessible_get_role(current, &error);
if (error != NULL) { g_error_free(error); break; }
if (role == ATSPI_ROLE_DOCUMENT_WEB || role == ATSPI_ROLE_DOCUMENT_FRAME ||
role == ATSPI_ROLE_EMBEDDED || role == ATSPI_ROLE_APPLICATION) break;
if (role == ATSPI_ROLE_FRAME || role == ATSPI_ROLE_WINDOW) {
g_object_unref(current);
return menu;
}
if (menu == NULL) {
if (role == ATSPI_ROLE_MENU) {
if (!inRow) break; // Ordinary menu entries keep their existing events.
AtspiStateSet *states = atspi_accessible_get_state_set(current);
gboolean showing = states != NULL &&
atspi_state_set_contains(states, ATSPI_STATE_SHOWING) &&
!atspi_state_set_contains(states, ATSPI_STATE_DEFUNCT);
if (states != NULL) g_object_unref(states);
if (!showing) break;
menu = g_object_ref(current);
} else if (role == ATSPI_ROLE_MENU_ITEM || role == ATSPI_ROLE_CHECK_MENU_ITEM ||
role == ATSPI_ROLE_RADIO_MENU_ITEM) {
inRow = TRUE;
}
}
AtspiAccessible *parent = atspi_accessible_get_parent(current, &error);
g_object_unref(current);
current = parent;
if (error != NULL) { g_error_free(error); break; }
}
if (current != NULL) g_object_unref(current);
if (menu != NULL) g_object_unref(menu);
return NULL;
}
const gchar *cthulhu_accessible_path(AtspiAccessible *source) {
return source != NULL ? ATSPI_OBJECT(source)->path : NULL;
}
+1
View File
@@ -115,6 +115,7 @@ gboolean cthulhu_in_active_document(AtspiAccessible *source);
gboolean cthulhu_in_inactive_window(AtspiAccessible *source);
gboolean cthulhu_active_menu_window(AtspiAccessible *window);
AtspiAccessible *cthulhu_popup_menu_target(AtspiAccessible *window);
AtspiAccessible *cthulhu_inline_menu_container(AtspiAccessible *source);
const gchar *cthulhu_accessible_path(AtspiAccessible *source);
const gchar *cthulhu_accessible_bus_name(AtspiAccessible *source);
AtspiAccessible *cthulhu_window_ancestor(AtspiAccessible *source);
+1 -1
View File
@@ -274,7 +274,7 @@ type Event struct {
NavigationEcho bool
SnapshotDuration time.Duration
Type string
// NativeType retains an AT-SPI text-change origin suffix for debug logs.
// NativeType retains the original AT-SPI type when an event is normalized.
// Presentation policies use the canonical Type.
NativeType string
Source Snapshot
+10
View File
@@ -52,6 +52,9 @@ func bindingDefaults(layout KeyboardLayout, scope string) CommandBindings {
}
func CommandLabel(command Command) string {
if binding, ok := LookupDocumentList(command); ok {
return "List " + strings.ToLower(binding.Title)
}
switch command {
case PresentCellFormula:
return "Present the formula for the current cell"
@@ -173,6 +176,13 @@ func BindingRows(layout KeyboardLayout, overrides []BindingOverride) []BindingRo
for chord, command := range bindingDefaults(layout, scope) {
groups[command] = append(groups[command], FormatCommandChord(chord))
}
if scope == "browse" {
for _, binding := range DocumentListBindings() {
if _, exists := groups[binding.Command]; !exists {
groups[binding.Command] = nil
}
}
}
if scope == "global" {
for _, command := range []Command{PresentCellFormula, SetColumnHeadersRow, ClearColumnHeadersRow, SetRowHeadersColumn, ClearRowHeadersColumn, PresentDate, StickyFocusMode, StickyBrowseMode, PresentPreviousNotification, PresentNextNotification, OCRObject, OCRWindow, OCRDesktop, OCRClipboard} {
if _, exists := groups[command]; !exists {
+5
View File
@@ -435,6 +435,11 @@ func BrowseCommandBindings(layout KeyboardLayout) CommandBindings {
bindings[CommandChord{Symbol: binding.symbol}] = binding.next
bindings[CommandChord{Symbol: binding.symbol, Modifiers: 1}] = binding.previous
}
for _, binding := range DocumentListBindings() {
if binding.Symbol != 0 {
bindings[CommandChord{Symbol: binding.Symbol, Modifiers: 1 | 8}] = binding.Command
}
}
headingLevels := []struct {
level int
next Command
+88
View File
@@ -0,0 +1,88 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package input
const (
DocumentListBlockQuotes Command = "document-list-block-quotes"
DocumentListButtons Command = "document-list-buttons"
DocumentListCheckBoxes Command = "document-list-check-boxes"
DocumentListComboBoxes Command = "document-list-combo-boxes"
DocumentListEntries Command = "document-list-entries"
DocumentListFormFields Command = "document-list-form-fields"
DocumentListHeadings Command = "document-list-headings"
DocumentListImages Command = "document-list-images"
DocumentListLandmarks Command = "document-list-landmarks"
DocumentListLists Command = "document-list-lists"
DocumentListListItems Command = "document-list-list-items"
DocumentListParagraphs Command = "document-list-paragraphs"
DocumentListRadioButtons Command = "document-list-radio-buttons"
DocumentListTables Command = "document-list-tables"
DocumentListLinks Command = "document-list-links"
DocumentListUnvisitedLinks Command = "document-list-unvisited-links"
DocumentListVisitedLinks Command = "document-list-visited-links"
DocumentListLargeObjects Command = "document-list-large-objects"
DocumentListClickables Command = "document-list-clickables"
DocumentListHeadingsAtLevel1 Command = "document-list-headings-at-level-1"
DocumentListHeadingsAtLevel2 Command = "document-list-headings-at-level-2"
DocumentListHeadingsAtLevel3 Command = "document-list-headings-at-level-3"
DocumentListHeadingsAtLevel4 Command = "document-list-headings-at-level-4"
DocumentListHeadingsAtLevel5 Command = "document-list-headings-at-level-5"
DocumentListHeadingsAtLevel6 Command = "document-list-headings-at-level-6"
DocumentListAnnotations Command = "document-list-annotations"
DocumentListInternalFrames Command = "document-list-internal-frames"
DocumentListMath Command = "document-list-math"
)
// DocumentListBinding describes browser lists independently of presentation.
// Unbound commands remain available in the shortcut editor, as in Orca.
type DocumentListBinding struct {
Command Command
Symbol uint32
Title string
Next Command
Level int
}
func DocumentListBindings() []DocumentListBinding {
return []DocumentListBinding{
{Command: DocumentListBlockQuotes, Symbol: 'q', Title: "Block quotes", Next: DocumentNextBlockQuote},
{Command: DocumentListButtons, Symbol: 'b', Title: "Buttons", Next: DocumentNextButton},
{Command: DocumentListCheckBoxes, Symbol: 'x', Title: "Check boxes", Next: DocumentNextCheckBox},
{Command: DocumentListComboBoxes, Symbol: 'c', Title: "Combo boxes", Next: DocumentNextComboBox},
{Command: DocumentListEntries, Symbol: 'e', Title: "Text entries", Next: DocumentNextEntry},
{Command: DocumentListFormFields, Symbol: 'f', Title: "Form fields", Next: DocumentNextFormField},
{Command: DocumentListHeadings, Symbol: 'h', Title: "Headings", Next: DocumentNextHeading},
{Command: DocumentListImages, Symbol: 'g', Title: "Images", Next: DocumentNextImage},
{Command: DocumentListLandmarks, Symbol: 'm', Title: "Landmarks", Next: DocumentNextLandmark},
{Command: DocumentListLists, Symbol: 'l', Title: "Lists", Next: DocumentNextList},
{Command: DocumentListListItems, Symbol: 'i', Title: "List items", Next: DocumentNextListItem},
{Command: DocumentListParagraphs, Symbol: 'p', Title: "Paragraphs", Next: DocumentNextParagraph},
{Command: DocumentListRadioButtons, Symbol: 'r', Title: "Radio buttons", Next: DocumentNextRadioButton},
{Command: DocumentListTables, Symbol: 't', Title: "Tables", Next: DocumentNextTable},
{Command: DocumentListLinks, Symbol: 'k', Title: "Links", Next: DocumentNextLink},
{Command: DocumentListUnvisitedLinks, Symbol: 'u', Title: "Unvisited links", Next: DocumentNextUnvisitedLink},
{Command: DocumentListVisitedLinks, Symbol: 'v', Title: "Visited links", Next: DocumentNextVisitedLink},
{Command: DocumentListLargeObjects, Symbol: 'o', Title: "Large objects", Next: DocumentNextChunk},
{Command: DocumentListClickables, Symbol: 'a', Title: "Clickables", Next: DocumentNextClickable},
{Command: DocumentListHeadingsAtLevel1, Symbol: '1', Title: "Headings at level 1", Next: DocumentNextHeading, Level: 1},
{Command: DocumentListHeadingsAtLevel2, Symbol: '2', Title: "Headings at level 2", Next: DocumentNextHeading, Level: 2},
{Command: DocumentListHeadingsAtLevel3, Symbol: '3', Title: "Headings at level 3", Next: DocumentNextHeading, Level: 3},
{Command: DocumentListHeadingsAtLevel4, Symbol: '4', Title: "Headings at level 4", Next: DocumentNextHeading, Level: 4},
{Command: DocumentListHeadingsAtLevel5, Symbol: '5', Title: "Headings at level 5", Next: DocumentNextHeading, Level: 5},
{Command: DocumentListHeadingsAtLevel6, Symbol: '6', Title: "Headings at level 6", Next: DocumentNextHeading, Level: 6},
{Command: DocumentListAnnotations, Title: "Annotations"},
{Command: DocumentListInternalFrames, Title: "Internal frames"},
{Command: DocumentListMath, Title: "Math"},
}
}
func LookupDocumentList(command Command) (DocumentListBinding, bool) {
for _, binding := range DocumentListBindings() {
if binding.Command == command {
return binding, true
}
}
return DocumentListBinding{}, false
}
+46
View File
@@ -0,0 +1,46 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package input
import "testing"
func TestDocumentListShortcutsAndCustomization(t *testing.T) {
for _, layout := range []KeyboardLayout{DesktopKeyboardLayout, LaptopKeyboardLayout} {
bindings := BrowseCommandBindings(layout)
bound := 0
for _, binding := range DocumentListBindings() {
if binding.Symbol == 0 {
continue
}
bound++
chord := CommandChord{Symbol: binding.Symbol, Modifiers: 9}
if bindings[chord] != binding.Command || DefaultCommandBindings(layout)[chord] != "" {
t.Fatalf("%s has incorrect scope or shortcut", binding.Command)
}
outside := NewCommandRouter([]uint32{0xff63}, DefaultCommandBindings(layout))
if consumed, command := outside.Handle(binding.Symbol, 100, 9, true); consumed || command != "" {
t.Fatalf("%s intercepted outside browse mode", binding.Command)
}
router := NewCommandRouter([]uint32{0xff63}, bindings)
if consumed, command := router.Handle(binding.Symbol, 100, 9, true); !consumed || command != binding.Command {
t.Fatalf("%s was not dispatched: %v %s", binding.Command, consumed, command)
}
if consumed, _ := router.Handle(binding.Symbol, 100, 9, false); !consumed {
t.Fatal("lost list key release")
}
}
if bound != 25 {
t.Fatalf("got %d shortcuts, want 19 types and 6 heading levels", bound)
}
overrides := []BindingOverride{{Layout: layout, Scope: "browse", Command: DocumentListMath, Keys: []string{"Alt+Shift+y"}}}
if err := ValidateBindingOverrides(overrides); err != nil {
t.Fatal(err)
}
custom, err := ResolveCommandBindings(layout, true, overrides)
if err != nil || custom[CommandChord{Symbol: 'y', Modifiers: 9}] != DocumentListMath {
t.Fatalf("unbound list customization: %v", err)
}
}
}
+15 -1
View File
@@ -100,6 +100,20 @@ type Session struct {
const maxTextLength = 8192
func NewSession(form Form) (*Session, error) {
return new_session(form, 256)
}
// NewNavigationSession is for core document lists. External forms retain
// their smaller protocol limit. The selected row follows the reading caret.
func NewNavigationSession(title string, items []Item, selected int) (*Session, error) {
session, err := new_session(Form{Title: title, Elements: []Element{{ID: "items", Label: title, Kind: List, Items: items}}}, 65536)
if err == nil && selected >= 0 && selected < len(items) && !items[selected].Disabled {
session.elements[0].selected = selected
}
return session, err
}
func new_session(form Form, maxItems int) (*Session, error) {
if form.Title == "" || len(form.Title) > 1024 || len(form.Elements) == 0 || len(form.Elements) > 64 {
return nil, errors.New("a titled form with 1 to 64 controls is required")
}
@@ -127,7 +141,7 @@ func NewSession(form Form) (*Session, error) {
element.Items = append([]Item(nil), element.Items...)
state := elementState{element: element, selected: -1, caret: len([]rune(element.Text)), anchor: len([]rune(element.Text))}
itemIDs := map[string]bool{}
if len(element.Items) > 256 {
if len(element.Items) > maxItems {
return nil, errors.New("too many list items")
}
for index, item := range element.Items {
+18
View File
@@ -5,10 +5,28 @@
package interaction
import (
"strconv"
"strings"
"testing"
)
func TestNavigationListsRetainExternalFormLimit(t *testing.T) {
items := make([]Item, 300)
for index := range items {
items[index] = Item{ID: strconv.Itoa(index), Label: "Example item"}
}
if _, err := NewSession(Form{Title: "External form", Elements: []Element{{ID: "items", Label: "Items", Kind: List, Items: items}}}); err == nil {
t.Fatal("external form exceeded its protocol limit")
}
session, err := NewNavigationSession("Links", items, 299)
if err != nil {
t.Fatal(err)
}
if effect := session.Handle(Event{Key: Enter}); effect.Activated != "299" {
t.Fatalf("lost initial selection: %+v", effect)
}
}
func TestListNavigationAndIndependentSessions(t *testing.T) {
form := Form{Title: "Actions", Elements: []Element{{ID: "actions", Label: "Actions", Kind: List, Items: []Item{{ID: "click", Label: "Click"}, {ID: "skip", Label: "Unavailable", Disabled: true}, {ID: "press", Label: "Press"}, {ID: "ancestor", Label: "Click ancestor"}}}}}
first, err := NewSession(form)
@@ -433,6 +433,12 @@ func TestNativeBrowseKeyTransitions(t *testing.T) {
listener.SetBrowseNavigation(false)
key("keyup", "h")
ready(false)
key("key", "alt+shift+k", "alt+shift+a")
select {
case got := <-listener.Commands():
t.Fatalf("list shortcut consumed outside browse mode: %s", got.Command)
case <-time.After(100 * time.Millisecond):
}
listener.SetBrowseNavigation(true)
ready(true)
key("key", "Num_Lock")
+10 -1
View File
@@ -315,6 +315,9 @@ const (
DocumentVisitedLink
DocumentClickable
DocumentChunk
DocumentAnnotation
DocumentInternalFrame
DocumentMath
)
// Move searches in accessible tree order, wrapping once to the opposite end.
@@ -425,7 +428,7 @@ func (cursor *DocumentCursor) Move(ctx context.Context, target DocumentTarget, l
}
func validDocumentTarget(target DocumentTarget) bool {
return target >= DocumentHeading && target <= DocumentChunk
return target >= DocumentHeading && target <= DocumentMath
}
func documentTargetMatches(target DocumentTarget, node atspi.DocumentNode, level int) bool {
@@ -474,6 +477,12 @@ func documentTargetMatches(target DocumentTarget, node atspi.DocumentNode, level
return snapshot.Role == "link" && snapshot.Focusable && snapshot.Visited
case DocumentClickable:
return snapshot.SupportsClick && !snapshot.Focusable && !documentClickExcludedRole(snapshot) && !documentHasXMLRole(snapshot, "gridcell")
case DocumentAnnotation:
return snapshot.Role == "content deletion" || snapshot.Role == "content insertion" || snapshot.Role == "mark"
case DocumentInternalFrame:
return snapshot.Role == "internal frame"
case DocumentMath:
return snapshot.Role == "math"
case DocumentChunk:
if documentChunkRole(snapshot) {
return snapshot.Role == "heading" || snapshot.Role == "list" || snapshot.Role == "table" ||
@@ -0,0 +1,109 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package review
import (
"context"
"strings"
"testing"
"time"
"unicode/utf8"
"git.stormux.org/storm/cthulhu/internal/atspi"
)
func editable_caret_cursor(t *testing.T) (*DocumentCursor, caretTextReader) {
t.Helper()
reader := caretTextReader{
webID("/before"): "Suggested wording",
webID("/editor"): "\ufffc\ufffc",
webID("/first"): "Review the code.\nReport actionable issues.",
webID("/second"): "Explain the evidence.",
webID("/secret"): "Private fixture text",
webID("/after"): "Following paragraph",
}
node := func(path, role string, editable bool, children ...string) atspi.TreeNode {
snapshot := atspi.NewSnapshot(path, "", role, "", "")
snapshot.BusName = webID(path).BusName
snapshot.Editable = editable
snapshot.CharacterCount = utf8.RuneCountInString(reader[webID(path)])
result := atspi.TreeNode{Snapshot: snapshot}
for _, child := range children {
result.Children = append(result.Children, webID(child))
}
return result
}
tree, err := atspi.NewAccessibleTree(webID("/doc"), []atspi.TreeNode{
node("/doc", "document web", false, "/before", "/editor", "/password", "/after"),
node("/before", "static", false),
node("/editor", "entry", true, "/paragraph", "/second"),
node("/paragraph", "paragraph", true, "/first"),
node("/first", "static", true),
node("/second", "static", true),
node("/password", "password text", true, "/secret"),
// Even a provider which omits editable state on a password child
// must not expose that child's text through browse navigation.
node("/secret", "static", false),
node("/after", "static", false),
})
if err != nil {
t.Fatal(err)
}
cursor, err := NewDocumentCursor(tree)
if err != nil {
t.Fatal(err)
}
if _, err := cursor.SyncFocus(context.Background(), webID("/before")); err != nil {
t.Fatal(err)
}
return cursor, reader
}
func TestDocumentCaretReadsEditableDescendants(t *testing.T) {
ctx := context.Background()
cursor, reader := editable_caret_cursor(t)
for _, step := range []struct {
command DocumentCaretCommand
want string
}{
{CaretNextLine, "Review the code."},
{CaretNextLine, "Report actionable issues."},
{CaretNextLine, "Explain the evidence."},
{CaretNextLine, "Following paragraph"},
{CaretPreviousLine, "Explain the evidence."},
{CaretPreviousLine, "Report actionable issues."},
{CaretLineStart, "R"},
{CaretNextCharacter, "e"},
{CaretNextWord, "Report"},
{CaretPreviousLine, "Review the code."},
{CaretPreviousLine, "Suggested wording"},
} {
move, err := cursor.MoveCaret(ctx, reader, step.command)
if err != nil || !move.Moved || strings.TrimSpace(move.Text) != step.want {
t.Fatalf("%s: %q at %s, moved %t, %v; want %q", step.command, move.Text,
move.Position.Snapshot.Path, move.Moved, err, step.want)
}
}
}
func TestReadAllIncludesEditableDescendants(t *testing.T) {
cursor, reader := editable_caret_cursor(t)
capturer := &readAllCapturer{tree: cursor.tree, caretTextReader: reader}
worker, err := NewWorker(capturer, time.Second)
if err != nil {
t.Fatal(err)
}
worker.SyncFocus(webID("/before"))
result := worker.readAll(context.Background(), cursor)
want := []string{"Suggested wording", "Review the code.", "Report actionable issues.", "Explain the evidence.", "Following paragraph"}
if result.Err != nil || len(result.ReadAllLines) != len(want) {
t.Fatalf("Read All: %d lines, %v; want %d", len(result.ReadAllLines), result.Err, len(want))
}
for index, line := range result.ReadAllLines {
if got := strings.TrimSpace(line.Contents[0].Text); got != want[index] {
t.Fatalf("Read All line %d: %q; want %q", index, got, want[index])
}
}
}
+8 -1
View File
@@ -73,6 +73,10 @@ func caret_node_indices(nodes []atspi.DocumentNode, object atspi.ObjectReference
if node.Hidden || skipped[node.Reference.ID()] {
continue
}
if snapshot.Role == "password text" {
skipChildren(node.Children)
continue
}
if !protected[node.Reference.ID()] && decorative_caret_glyph(node) {
continue
}
@@ -83,7 +87,10 @@ func caret_node_indices(nodes []atspi.DocumentNode, object atspi.ObjectReference
(node.Snapshot.Role == "entry" || node.Snapshot.Role == "text") &&
(node.Snapshot.CharacterCount == 0 || len(node.Children) == 0) {
indices = append(indices, index)
} else if !node.Snapshot.Editable && len(node.Children) == 0 && node.Snapshot.CharacterCount > 0 {
} else if len(node.Children) == 0 && node.Snapshot.CharacterCount > 0 {
// Rich editors expose their contents as editable text leaves.
// Browse reading must descend into them just like ordinary text;
// the entry's embedded-object placeholders are not its contents.
indices = append(indices, index)
}
}
+251
View File
@@ -0,0 +1,251 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package review
import (
"context"
"errors"
"strings"
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
)
// DocumentListSnapshot retains exact generation-checked references. Opening a list
// never moves the cursor. Selection cannot recapture or substitute an item.
type DocumentListSnapshot struct {
Entries []DocumentListEntry
Selected int
Target DocumentTarget
Level int
focus atspi.ObjectID
revision uint64
epoch uint64
tree *atspi.AccessibleTree
}
type DocumentListEntry struct {
DocumentPosition
Label string
}
type documentListReply struct {
list DocumentListSnapshot
err error
}
type documentListRequest struct {
ctx context.Context
target DocumentTarget
level int
result chan documentListReply
}
type documentListSelection struct {
list DocumentListSnapshot
index int
}
// ListDocument is called by the UI worker, never by the input/event loops.
func (worker *Worker) ListDocument(ctx context.Context, target DocumentTarget, level int) (DocumentListSnapshot, error) {
if err := ctx.Err(); err != nil {
return DocumentListSnapshot{}, err
}
if !validDocumentTarget(target) || level < 0 || level > 6 || target != DocumentHeading && level != 0 {
return DocumentListSnapshot{}, ErrInvalidDirection
}
worker.stateMutex.Lock()
done, started := worker.done, worker.started
worker.stateMutex.Unlock()
if !started {
return DocumentListSnapshot{}, ErrNoCurrent
}
query := &documentListRequest{ctx: ctx, target: target, level: level, result: make(chan documentListReply, 1)}
select {
case worker.requests <- request{documentList: query}:
case <-ctx.Done():
return DocumentListSnapshot{}, ctx.Err()
case <-done:
return DocumentListSnapshot{}, ErrNoCurrent
}
select {
case reply := <-query.result:
return reply.list, reply.err
case <-ctx.Done():
return DocumentListSnapshot{}, ctx.Err()
case <-done:
return DocumentListSnapshot{}, ErrNoCurrent
}
}
func (worker *Worker) SelectDocumentList(list DocumentListSnapshot, index int) error {
if index < 0 || index >= len(list.Entries) || list.tree == nil {
return ErrInvalidDirection
}
select {
case worker.requests <- request{documentEpoch: list.epoch, listSelection: &documentListSelection{list: list, index: index}, queuedAt: time.Now()}:
return nil
default:
return ErrRequestQueueFull
}
}
func (worker *Worker) listDocument(ctx context.Context, cursor *DocumentCursor, query *documentListRequest) (DocumentListSnapshot, error) {
if err := query.ctx.Err(); err != nil {
return DocumentListSnapshot{}, err
}
worker.stateMutex.Lock()
list := DocumentListSnapshot{Target: query.target, Level: query.level, focus: worker.latestFocus, revision: worker.focusRevision, epoch: worker.documentEpoch.Load()}
worker.stateMutex.Unlock()
if !list.focus.Valid() {
return list, ErrNoCurrent
}
captureContext, cancel := context.WithTimeout(ctx, worker.captureTimeout)
defer cancel()
stop := context.AfterFunc(query.ctx, cancel)
defer stop()
tree, err := worker.capturer.CaptureDocument(captureContext, list.focus)
if err != nil {
return list, err
}
if tree == nil {
return list, errors.New("capture document list: nil tree")
}
ref, err := tree.Reference(list.focus)
if err != nil {
return list, err
}
doc, err := tree.Document(captureContext, ref)
if err != nil {
return list, err
}
current := list.focus
if cursor != nil {
cursor.mu.Lock()
if len(cursor.places) > 0 {
place := cursor.places[len(cursor.places)-1]
if place.document == doc.Context.TopLevel && (place.focus.ID() == list.focus || place.object.ID() == list.focus) {
current = place.object.ID()
}
}
cursor.mu.Unlock()
}
list.tree = tree
// Unnamed clickable sections and containers often expose their readable
// label only in static descendants. Assemble bounded labels once, in
// reverse tree order, instead of rescanning each candidate's subtree.
labels := make(map[atspi.ObjectID]string, len(doc.Nodes))
for index := len(doc.Nodes) - 1; index >= 0; index-- {
node := doc.Nodes[index]
if node.Hidden {
continue
}
label := node.Snapshot.Name
if node.Snapshot.Role != "password text" {
if label == "" {
label = node.Snapshot.Text
}
if label == "" {
var parts []string
size := 0
for _, child := range node.Children {
if part := labels[child]; part != "" {
parts = append(parts, part)
size += len(part)
}
if size >= 900 {
break
}
}
label = strings.Join(parts, " ")
}
}
runes := []rune(label)
if len(runes) > 225 {
label = string(runes[:225])
}
labels[node.Reference.ID()] = label
}
parents := make(map[atspi.ObjectID]atspi.ObjectID, len(doc.Nodes))
indices := make(map[atspi.ObjectID]int)
for _, node := range doc.Nodes {
parents[node.Reference.ID()] = node.Parent
if node.Hidden || !documentTargetMatches(query.target, node, query.level) {
continue
}
indices[node.Reference.ID()] = len(list.Entries)
list.Entries = append(list.Entries, DocumentListEntry{DocumentPosition: DocumentPosition{Document: doc.Context.TopLevel, Object: node.Reference, Snapshot: node.Snapshot, Origin: StructuralNavigation}, Label: labels[node.Reference.ID()]})
}
for id := current; id.Valid(); id = parents[id] {
if index, ok := indices[id]; ok {
list.Selected = index
break
}
}
if !worker.currentDocumentList(list) {
return DocumentListSnapshot{}, atspi.ErrStaleObject
}
return list, captureContext.Err()
}
func (worker *Worker) currentDocumentList(list DocumentListSnapshot) bool {
worker.stateMutex.Lock()
defer worker.stateMutex.Unlock()
return list.focus == worker.latestFocus && list.revision == worker.focusRevision && list.epoch == worker.documentEpoch.Load()
}
func (worker *Worker) selectDocumentList(ctx context.Context, cursor *DocumentCursor, selection *documentListSelection) (Result, *DocumentCursor) {
list := selection.list
result := Result{DocumentEpoch: list.epoch, DocumentTarget: list.Target, DocumentLevel: list.Level, DocumentFocus: list.focus}
if !worker.currentDocumentList(list) {
result.Err = atspi.ErrStaleObject
return result, cursor
}
position := list.Entries[selection.index]
if _, err := list.tree.Resolve(position.Object); err != nil {
result.Err = err
return result, cursor
}
doc, err := list.tree.Document(ctx, position.Object)
if err != nil {
result.Err = err
return result, cursor
}
if doc.Context.TopLevel != position.Document {
result.Err = atspi.ErrStaleObject
return result, cursor
}
found := false
for _, node := range doc.Nodes {
if node.Reference == position.Object && !node.Hidden && documentTargetMatches(list.Target, node, list.Level) {
found = true
break
}
}
if !found {
result.Err = atspi.ErrStaleObject
return result, cursor
}
if cursor == nil || cursor.tree != list.tree {
cursor, _ = NewDocumentCursor(list.tree)
}
if _, err := cursor.SyncFocus(ctx, list.focus); err != nil {
result.Err = err
return result, cursor
}
if !worker.currentDocumentList(list) {
result.Err = atspi.ErrStaleObject
return result, cursor
}
cursor.mu.Lock()
place := &cursor.places[len(cursor.places)-1]
place.object, place.offset, place.origin = position.Object, 0, StructuralNavigation
place.tableCell = atspi.ObjectID{}
remember_text_parent(doc, place)
result.DocumentPosition = documentPosition(doc, *place)
cursor.mu.Unlock()
result.Moved = true
// Use the exact validated target; retry/recapture belongs to ordinary
// navigation and must not choose a replacement for a menu selection.
return worker.placeDocument(ctx, cursor, result), cursor
}
+143
View File
@@ -0,0 +1,143 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package review
import (
"context"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
)
func TestDocumentListPreservesCursorAndSelectsExactTarget(t *testing.T) {
tree, cursor := webCursor(t)
ctx := context.Background()
if _, err := cursor.SyncFocus(ctx, webID("/doc")); err != nil {
t.Fatal(err)
}
capturer := &readAllCapturer{tree: tree}
worker, _ := NewWorker(capturer, time.Second)
worker.SyncFocus(webID("/doc"))
before := cursor.diagnostic_state()
list, err := worker.listDocument(ctx, cursor, &documentListRequest{ctx: ctx, target: DocumentHeading})
if err != nil || len(list.Entries) != 2 {
t.Fatalf("heading list: %+v %v", list, err)
}
if after := cursor.diagnostic_state(); after != before {
t.Fatalf("opening moved cursor: %+v -> %+v", before, after)
}
result, cursor := worker.selectDocumentList(ctx, cursor, &documentListSelection{list: list, index: 1})
if result.Err != nil || !result.Moved || result.DocumentPosition.Object != list.Entries[1].Object {
t.Fatalf("selection: %+v", result)
}
list, err = worker.listDocument(ctx, cursor, &documentListRequest{ctx: ctx, target: DocumentHeading})
if err != nil || list.Selected != 1 {
t.Fatalf("did not preserve current row: %+v %v", list, err)
}
filtered, err := worker.listDocument(ctx, cursor, &documentListRequest{ctx: ctx, target: DocumentHeading, level: 2})
if err != nil || len(filtered.Entries) != 2 || filtered.Entries[0].Snapshot.HeadingLevel != 2 {
t.Fatalf("level filter: %+v %v", filtered, err)
}
}
func TestDocumentListRejectsChangedTargetsAndOwnership(t *testing.T) {
for _, change := range []string{"target removed", "target reused", "target hidden", "focus changed", "document reloaded"} {
t.Run(change, func(t *testing.T) {
tree, cursor := webCursor(t)
ctx := context.Background()
worker, _ := NewWorker(&readAllCapturer{tree: tree}, time.Second)
worker.SyncFocus(webID("/doc"))
list, err := worker.listDocument(ctx, cursor, &documentListRequest{ctx: ctx, target: DocumentHeading})
if err != nil {
t.Fatal(err)
}
switch change {
case "target removed":
tree.Invalidate(list.Entries[0].Object.ID())
case "target reused":
tree.Invalidate(list.Entries[0].Object.ID())
if err := tree.Replace(webID("/root"), webNodes()); err != nil {
t.Fatal(err)
}
case "target hidden":
nodes := webNodes()
nodes[3].Geometry = atspi.Geometry{VisibilityKnown: true}
if err := tree.Replace(webID("/root"), nodes); err != nil {
t.Fatal(err)
}
case "focus changed":
worker.SyncFocus(webID("/chrome"))
case "document reloaded":
worker.ResetDocument(webID("/doc"))
}
result, _ := worker.selectDocumentList(ctx, cursor, &documentListSelection{list: list, index: 0})
if result.Err == nil || result.Moved {
t.Fatalf("stale selection succeeded: %+v", result)
}
})
}
}
func TestDocumentListDescendantLabelsExcludeHiddenAndPasswordText(t *testing.T) {
node := func(path, role, text string, children ...string) atspi.TreeNode {
snapshot := atspi.Snapshot{BusName: webID(path).BusName, Path: path, Role: role, Text: text}
entry := atspi.TreeNode{Snapshot: snapshot}
for _, child := range children {
entry.Children = append(entry.Children, webID(child))
}
return entry
}
nodes := []atspi.TreeNode{
node("/doc", "document web", "", "/clickable"),
node("/clickable", "section", "", "/text", "/hidden", "/password"),
node("/text", "static", "Clickable text"),
node("/hidden", "static", "Hidden content"),
node("/password", "password text", "private fixture password"),
}
nodes[1].Snapshot.SupportsClick = true
nodes[3].Geometry = atspi.Geometry{VisibilityKnown: true}
tree, err := atspi.NewAccessibleTree(webID("/doc"), nodes)
if err != nil {
t.Fatal(err)
}
worker, _ := NewWorker(&readAllCapturer{tree: tree}, time.Second)
worker.SyncFocus(webID("/doc"))
ctx := context.Background()
list, err := worker.listDocument(ctx, nil, &documentListRequest{ctx: ctx, target: DocumentClickable})
if err != nil || len(list.Entries) != 1 || list.Entries[0].Label != "Clickable text" {
t.Fatalf("unsafe or missing descendant label: %+v %v", list, err)
}
}
func TestWorkerDocumentListUsesOrderedRequests(t *testing.T) {
tree, _ := webCursor(t)
worker, cancel := startWorker(t, &readAllCapturer{tree: tree}, time.Second)
defer cancel()
worker.SyncFocus(webID("/doc"))
ctx, stop := context.WithTimeout(context.Background(), time.Second)
defer stop()
list, err := worker.ListDocument(ctx, DocumentHeading, 0)
if err != nil {
t.Fatal(err)
}
if err := worker.SelectDocumentList(list, 1); err != nil {
t.Fatal(err)
}
result := waitResult(t, worker)
if result.Err != nil || !result.Moved || result.DocumentPosition.Object != list.Entries[1].Object {
t.Fatalf("queued selection: %+v", result)
}
}
func TestAdditionalDocumentListRoles(t *testing.T) {
for target, roles := range map[DocumentTarget][]string{DocumentAnnotation: {"content deletion", "content insertion", "mark"}, DocumentInternalFrame: {"internal frame"}, DocumentMath: {"math"}} {
for _, role := range roles {
if !documentTargetMatches(target, atspi.DocumentNode{Snapshot: atspi.Snapshot{Role: role}}, 0) {
t.Fatalf("missing %s", role)
}
}
}
}
@@ -166,6 +166,16 @@ func TestNativeBrowserFragmentLines(t *testing.T) {
if !rankFound {
t.Fatal("Read All did not reach member rank")
}
var editableLines []string
for _, line := range result.ReadAllLines {
text := strings.TrimSpace(line.Contents[0].Text)
if text == "Review the code." || text == "Report actionable issues." || text == "After the suggestion." {
editableLines = append(editableLines, text)
}
}
if strings.Join(editableLines, "|") != "Review the code.|Report actionable issues.|After the suggestion." {
t.Fatalf("Read All skipped or duplicated embedded editor text: %q", editableLines)
}
// Fragmented headings must also hand Down to following content as a unit.
if _, err := cursor.MoveCaret(ctx, adapter, CaretDocumentStart); err != nil {
t.Fatal(err)
@@ -178,4 +188,27 @@ func TestNativeBrowserFragmentLines(t *testing.T) {
if err != nil || next.Text != "Code first" {
t.Fatalf("Down after split heading: %+v, %v", next, err)
}
move, err = cursor.Move(ctx, DocumentHeading, 2, false)
if err != nil || !move.Moved || move.Position.Snapshot.Name != "Embedded suggestion" {
t.Fatalf("editor heading: %q, %v", move.Position.Snapshot.Name, err)
}
for _, step := range []struct {
command DocumentCaretCommand
want string
}{
{CaretNextLine, ""}, // Stop on the editor control before entering its text.
{CaretNextLine, "Review the code."},
{CaretNextLine, "Report actionable issues."},
{CaretNextLine, "After the suggestion."},
{CaretPreviousLine, "Report actionable issues."},
{CaretPreviousLine, "Review the code."},
{CaretNextCharacter, "e"},
{CaretNextWord, "Review"},
} {
movement, err := cursor.MoveCaret(ctx, adapter, step.command)
if err != nil || !movement.Moved || strings.TrimSpace(movement.Text) != step.want {
t.Fatalf("embedded editor %s: %q (%s, %s, count %d), moved %t, %v; want %q", step.command, movement.Text,
movement.Position.Snapshot.Role, movement.Position.Snapshot.Path, movement.Position.Snapshot.CharacterCount, movement.Moved, err, step.want)
}
}
}
+12 -5
View File
@@ -261,6 +261,11 @@ func (worker *Worker) run(ctx context.Context, done chan<- struct{}) {
documentEpoch = epoch
failedDocumentBefore = time.Time{}
}
if request.documentList != nil {
list, err := worker.listDocument(ctx, document, request.documentList)
request.documentList.result <- documentListReply{list, err}
continue
}
if request.interest != nil {
interest, err := resolve_interest(request.interest.ctx, interestFocus, explicitInterest, cursor, flat, flatTree, document)
worker.stateMutex.Lock()
@@ -275,18 +280,20 @@ func (worker *Worker) run(ctx context.Context, done chan<- struct{}) {
request.interest.result <- interestReply{interest, err}
continue
}
if (request.brailleRoute != nil || request.nativeCaret || request.readAll != nil || request.line || request.target != 0 || request.caretCommand != 0 || request.activate || request.fragment != "" || request.unit != 0) && request.documentEpoch != documentEpoch {
if (request.brailleRoute != nil || request.nativeCaret || request.readAll != nil || request.line || request.listSelection != nil || request.target != 0 || request.caretCommand != 0 || request.activate || request.fragment != "" || request.unit != 0) && request.documentEpoch != documentEpoch {
if request.brailleRoute != nil {
request.brailleRoute.done <- atspi.ErrStaleObject
}
continue
}
documentCommand := request.target != 0 || request.caretCommand != 0 || request.activate || request.fragment != ""
documentCommand := request.listSelection != nil || request.target != 0 || request.caretCommand != 0 || request.activate || request.fragment != ""
if documentCommand && request.queuedAt.Before(failedDocumentBefore) {
continue
}
var result Result
if request.brailleRoute != nil {
if request.listSelection != nil {
result, document = worker.selectDocumentList(ctx, document, request.listSelection)
} else if request.brailleRoute != nil {
result = worker.route_document_braille(ctx, document, request)
} else if request.nativeCaret {
result, document = worker.syncDocumentCaret(ctx, document, request)
@@ -335,7 +342,7 @@ func (worker *Worker) run(ctx context.Context, done chan<- struct{}) {
explicitInterest = flatInterest
} else if request.direction != 0 {
explicitInterest = objectInterest
} else if request.brailleRoute != nil || request.target != 0 || request.caretCommand != 0 || request.nativeCaret || request.fragment != "" || request.readPosition != nil {
} else if request.brailleRoute != nil || request.listSelection != nil || request.target != 0 || request.caretCommand != 0 || request.nativeCaret || request.fragment != "" || request.readPosition != nil {
explicitInterest = implicitInterest
}
worker.stateMutex.Lock()
@@ -352,7 +359,7 @@ func (worker *Worker) run(ctx context.Context, done chan<- struct{}) {
// can try again; successful navigation keeps its ordered steps.
failedDocumentBefore = time.Now()
}
if request.brailleRoute != nil || request.target != 0 || request.caretCommand != 0 || request.fragment != "" {
if request.brailleRoute != nil || request.listSelection != nil || request.target != 0 || request.caretCommand != 0 || request.fragment != "" {
result = worker.readDocumentBraille(ctx, result)
result.Elapsed = time.Since(request.queuedAt)
}
+2
View File
@@ -108,6 +108,8 @@ type Result struct {
// activation, fragment, flat unit, then object direction. Flags are deliberately
// retained; epoch and timeout retirement depend on their distinct combinations.
type request struct {
documentList *documentListRequest
listSelection *documentListSelection
brailleRoute *documentBrailleRequest
interest *interestRequest
nativeCaret bool