Files

159 lines
4.9 KiB
Go

// 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 <https://www.gnu.org/licenses/gpl-3.0.html>.
package atspi
import (
"context"
"errors"
)
var ErrNotInDocument = errors.New("accessible is outside web document content")
// DocumentContext identifies both the innermost document and the outermost
// document containing an object. Ancestors run from the object to the tree
// root. All data is copied under one tree lock; no native references escape.
type DocumentContext struct {
Document ObjectReference
TopLevel ObjectReference
Ancestors []Snapshot
}
// IsWebDocumentRole follows the shared Python web script's document roles.
// Generic text/spreadsheet documents must not enable browser key handling.
func IsWebDocumentRole(role string) bool {
return role == "document web" || role == "document frame" || role == "embedded"
}
func (tree *AccessibleTree) DocumentContext(reference ObjectReference) (DocumentContext, error) {
tree.mu.RLock()
defer tree.mu.RUnlock()
return tree.documentContextLocked(reference)
}
func (tree *AccessibleTree) documentContextLocked(reference ObjectReference) (DocumentContext, error) {
entry, err := tree.resolveLocked(reference)
if err != nil {
return DocumentContext{}, err
}
var result DocumentContext
for {
if entry.snapshotInvalid {
return DocumentContext{}, ErrStaleSnapshot
}
result.Ancestors = append(result.Ancestors, entry.snapshot)
if IsWebDocumentRole(entry.snapshot.Role) {
if !result.Document.Valid() {
result.Document = reference
}
result.TopLevel = reference
}
if !entry.hasParent {
break
}
id := entry.parent
entry = tree.nodes[id]
reference = newObjectReference(id, entry)
}
if !result.Document.Valid() {
return DocumentContext{}, ErrNotInDocument
}
return result, nil
}
// DocumentNode is one owned semantic object in document tree order. Visible
// does not mean on-screen: document navigation must include offscreen content.
type DocumentNode struct {
Reference ObjectReference
Parent ObjectID
Snapshot Snapshot
Geometry Geometry
Text VisibleText
Hidden bool
// ParentEditable is captured with the node so entry matching stays
// constant-time instead of rescanning the complete document per candidate.
ParentEditable bool
Children []ObjectID
}
type DocumentSnapshot struct {
Context DocumentContext
Nodes []DocumentNode
}
// Document copies the top-level document, including embedded documents but
// excluding browser chrome and other tabs. A stale/incomplete capture fails
// atomically. It does not use flat review's viewport-limited text ranges.
func (tree *AccessibleTree) Document(ctx context.Context, reference ObjectReference) (DocumentSnapshot, error) {
tree.mu.RLock()
defer tree.mu.RUnlock()
if err := ctx.Err(); err != nil {
return DocumentSnapshot{}, err
}
owner, err := tree.documentContextLocked(reference)
if err != nil {
return DocumentSnapshot{}, err
}
result := DocumentSnapshot{Context: owner}
type pendingNode struct {
id ObjectID
hidden bool
}
pending := []pendingNode{{id: owner.TopLevel.ID()}}
for len(pending) > 0 {
if err := ctx.Err(); err != nil {
return DocumentSnapshot{}, err
}
last := len(pending) - 1
next := pending[last]
pending = pending[:last]
entry := tree.nodes[next.id]
if entry.snapshotInvalid {
return DocumentSnapshot{}, ErrStaleSnapshot
}
if entry.childrenInvalid {
return DocumentSnapshot{}, ErrIncompleteTree
}
if entry.textInvalid {
return DocumentSnapshot{}, ErrStaleText
}
if entry.geometryInvalid {
return DocumentSnapshot{}, ErrStaleGeometry
}
hidden := next.hidden || entry.geometry.VisibilityKnown && !entry.geometry.Visible
parentEditable := false
if entry.hasParent {
parentEditable = tree.nodes[entry.parent].snapshot.Editable
}
result.Nodes = append(result.Nodes, DocumentNode{
Parent: entry.parent,
Reference: newObjectReference(next.id, entry),
Snapshot: entry.snapshot,
Geometry: entry.geometry,
Text: entry.text,
Hidden: hidden,
ParentEditable: parentEditable,
Children: entry.children,
})
for i := len(entry.children) - 1; i >= 0; i-- {
pending = append(pending, pendingNode{entry.children[i], hidden})
}
}
populate_document_rows(&result)
return result, nil
}