// 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 }