Files

219 lines
7.7 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"
"unicode/utf8"
"git.stormux.org/storm/cthulhu/internal/atspi"
)
// Assemble from a private cursor: collecting speech must not move the user's
// browse position to the end before any of it has actually been spoken.
func (worker *Worker) readAll(ctx context.Context, cursor *DocumentCursor) Result {
result := Result{DocumentFocus: worker.focus()}
if !result.DocumentFocus.Valid() {
result.Err = ErrNoCurrent
return result
}
if cursor != nil {
cursor = &DocumentCursor{tree: cursor.tree, active: cursor.active, places: append([]documentPlace(nil), cursor.places...)}
_, result.Err = cursor.syncActionFocus(ctx, result.DocumentFocus)
} else {
result.Err = ErrNoCurrent
}
if documentNeedsCapture(result.Err) {
cursor, result.Err = worker.refreshDocumentCursor(ctx, cursor, result.DocumentFocus)
}
if documentNeedsCapture(result.Err) {
cursor, result.Err = worker.refreshDocumentCursor(ctx, cursor, result.DocumentFocus)
}
if errors.Is(result.Err, atspi.ErrNotInDocument) {
if reader, ok := worker.nativeCapturerFor(result.DocumentFocus).(interface {
ReadFocusedText(context.Context, atspi.ObjectID) (atspi.DocumentContent, error)
}); ok {
deadline, cancel := context.WithTimeout(ctx, worker.captureTimeout)
defer cancel()
var content atspi.DocumentContent
content, result.Err = reader.ReadFocusedText(deadline, result.DocumentFocus)
if result.Err == nil {
result.ReadAllLines = focusedReadAllLines(content)
epoch := worker.documentEpoch.Load()
result.ReadAllProgress = func(index int) {
if index < 0 || index >= len(result.ReadAllLines) || content.Text == "" {
return
}
part := result.ReadAllLines[index].Contents[0]
part.Snapshot.Text = part.Text
position := DocumentPosition{Offset: part.Start, Snapshot: part.Snapshot}
select {
case worker.requests <- request{documentEpoch: epoch, readPosition: &position, readFocus: result.DocumentFocus}:
default:
}
}
}
}
return result
}
if result.Err != nil {
return result
}
reader, ok := worker.nativeCapturerFor(result.DocumentFocus).(DocumentCaretReader)
if !ok {
result.Err = errors.New("read all: document text reader unavailable")
return result
}
command := CaretReadLine
var previous DocumentPosition
var positions []DocumentPosition
epoch := worker.documentEpoch.Load()
result.ReadAllProgress = func(index int) {
if index < 0 || index >= len(positions) {
return
}
position := positions[index]
select {
case worker.requests <- request{documentEpoch: epoch, readPosition: &position, readTree: cursor.tree, readFocus: result.DocumentFocus}:
default:
}
}
bytes := 0
var movement DocumentCaretMove
var err error
for {
if err := ctx.Err(); err != nil {
result.Err = err
return result
}
movement, cursor, err = worker.readAllMove(ctx, cursor, reader, command, result.DocumentFocus)
if err != nil {
result.Err = err
return result
}
if !movement.Moved {
return result
}
if command == CaretNextLine && movement.Position.Object == previous.Object && movement.Position.Offset <= previous.Offset {
result.Err = errors.New("read all: text provider did not advance")
return result
}
previous = movement.Position
movement.Position.Snapshot.Misspelled = movement.Misspelled
line := atspi.DocumentLine{Document: movement.Position.Document, Contents: []atspi.DocumentContent{{Snapshot: movement.Position.Snapshot, Text: movement.Text, RawText: movement.RawText}}}
owner, err := cursor.tree.DocumentContext(movement.Position.Object)
if err != nil {
if !documentNeedsCapture(err) {
result.Err = err
return result
}
refreshed, refreshErr := worker.refreshDocumentCursor(ctx, cursor, result.DocumentFocus)
if refreshErr != nil {
result.Err = refreshErr
return result
}
cursor = refreshed
owner = atspi.DocumentContext{}
} else {
line.Context = owner.Ancestors
roles := map[string]bool{movement.Position.Snapshot.Role: true}
for _, ancestor := range owner.Ancestors {
if (ancestor.Role == "link" || ancestor.Role == "heading") && !roles[ancestor.Role] {
roles[ancestor.Role] = true
ancestor.Name = ""
line.Contents = append(line.Contents, atspi.DocumentContent{Snapshot: ancestor})
}
}
}
bytes += len(movement.Text) + len(movement.Position.Snapshot.Name)
if bytes > 1<<20 || len(result.ReadAllLines) >= 32768 {
result.Err = errors.New("read all: document exceeds speech capture limit")
return result
}
result.ReadAllLines = append(result.ReadAllLines, line)
positions = append(positions, movement.Position)
command = CaretNextLine
}
}
func (worker *Worker) readAllMove(ctx context.Context, cursor *DocumentCursor, reader DocumentCaretReader, command DocumentCaretCommand, focus atspi.ObjectID) (DocumentCaretMove, *DocumentCursor, error) {
deadline, cancel := context.WithTimeout(ctx, worker.captureTimeout)
movement, err := cursor.MoveCaret(deadline, reader, command)
cancel()
if err == nil || !documentNeedsCapture(err) {
return movement, cursor, err
}
cursor, refreshErr := worker.refreshDocumentCursor(ctx, cursor, focus)
if refreshErr != nil {
return movement, cursor, refreshErr
}
deadline, cancel = context.WithTimeout(ctx, worker.captureTimeout)
defer cancel()
movement, err = cursor.MoveCaret(deadline, reader, command)
return movement, cursor, err
}
func (worker *Worker) followReadAll(ctx context.Context, cursor *DocumentCursor, request request) (*DocumentCursor, bool) {
if !request.readPosition.Document.Valid() {
if setter, ok := worker.nativeCapturerFor(request.readFocus).(interface {
SetFocusedTextCaret(context.Context, atspi.ObjectID, int) error
}); ok {
deadline, cancel := context.WithTimeout(ctx, worker.captureTimeout)
defer cancel()
return cursor, setter.SetFocusedTextCaret(deadline, request.readFocus, request.readPosition.Offset) == nil
}
return cursor, false
}
if cursor == nil || cursor.tree != request.readTree {
cursor, _ = NewDocumentCursor(request.readTree)
}
if cursor == nil {
return nil, false
}
current, err := cursor.SyncFocus(ctx, request.readFocus)
position := request.readPosition
if err != nil || current.Document.ID() != position.Document.ID() {
return cursor, false
}
// A page can be recaptured while Say All is being assembled. Earlier
// lines retain references from the old tree, so bind the spoken object's
// identity to the tree that will receive the progress update.
object, err := cursor.tree.Reference(position.Object.ID())
if err != nil {
return cursor, false
}
owner, err := cursor.tree.DocumentContext(object)
if err != nil || owner.TopLevel != current.Document {
return cursor, false
}
snapshot, err := cursor.tree.Resolve(object)
if err != nil || position.Offset < 0 || position.Offset > snapshot.CharacterCount {
return cursor, false
}
position.Document, position.Object, position.Snapshot = current.Document, object, snapshot
place := &cursor.places[len(cursor.places)-1]
place.object, place.offset, place.origin = object, position.Offset, CaretNavigation
return cursor, true
}
func focusedReadAllLines(content atspi.DocumentContent) []atspi.DocumentLine {
if content.Text == "" {
return []atspi.DocumentLine{{Contents: []atspi.DocumentContent{content}}}
}
var lines []atspi.DocumentLine
offset := content.Start
for _, value := range strings.SplitAfter(content.Text, "\n") {
part := content
part.Start, part.End, part.Text = offset, offset+utf8.RuneCountInString(value), value
offset = part.End
if strings.TrimSpace(value) != "" {
lines = append(lines, atspi.DocumentLine{Contents: []atspi.DocumentContent{part}})
}
}
return lines
}