Files
cthulhu/internal/review/document_lists.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
}