159 lines
4.9 KiB
Go
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
|
|
}
|