219 lines
7.7 KiB
Go
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
|
|
}
|