// Cthulhu screen reader. // Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // // This program is free software: you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation, either version 3 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along with // this program. If not, see . package atspi import ( "context" "errors" ) var ErrNoReviewWindow = errors.New("focused object has no review window") // ReviewSnapshot owns one window's nodes and their generation-checked // references. Keeping references alongside nodes prevents a reused object ID // from inheriting an independent review position from a removed object. type ReviewSnapshot struct { Nodes []TreeNode References map[ObjectID]ObjectReference } // ReviewWindow copies the focused menu, dialog, or window in tree order under // one read lock. A consumer must not assemble layout from separately locked // getters: replacement between getters could mix two different captures. // Stale descendants fail the whole operation rather than yielding partial // screen content. Call this on the review worker, never the input/event loop. func (tree *AccessibleTree) ReviewWindow(ctx context.Context, focus ObjectID) (ReviewSnapshot, error) { tree.mu.RLock() defer tree.mu.RUnlock() if err := ctx.Err(); err != nil { return ReviewSnapshot{}, err } entry, ok := tree.nodes[focus] if !ok { return ReviewSnapshot{}, ErrObjectNotFound } root := focus for { switch entry.snapshot.Role { case "menu", "popup menu", "dialog", "alert", "frame", "window": goto found } if !entry.hasParent { return ReviewSnapshot{}, ErrNoReviewWindow } root = entry.parent entry = tree.nodes[root] } found: var nodes []TreeNode references := make(map[ObjectID]ObjectReference) pending := []ObjectID{root} for len(pending) > 0 { if err := ctx.Err(); err != nil { return ReviewSnapshot{}, err } id := pending[len(pending)-1] pending = pending[:len(pending)-1] entry, ok := tree.nodes[id] if !ok { return ReviewSnapshot{}, ErrStaleObject } if entry.childrenInvalid { return ReviewSnapshot{}, ErrIncompleteTree } if entry.geometryInvalid { return ReviewSnapshot{}, ErrStaleGeometry } if entry.textInvalid { return ReviewSnapshot{}, ErrStaleText } text := entry.text text.Ranges = append([]TextRange(nil), text.Ranges...) nodes = append(nodes, TreeNode{Snapshot: entry.snapshot, Geometry: entry.geometry, Text: text, Children: append([]ObjectID(nil), entry.children...)}) references[id] = newObjectReference(id, entry) for i := len(entry.children) - 1; i >= 0; i-- { pending = append(pending, entry.children[i]) } } return ReviewSnapshot{Nodes: nodes, References: references}, nil }