252 lines
7.6 KiB
Go
252 lines
7.6 KiB
Go
// 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.documentCaptureTimeout)
|
|
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.DocumentView(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.DocumentView(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
|
|
}
|