// 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 }