Cthulhu is no longer a fork. Now it is a screen reader written in Golang.
This commit is contained in:
1 parent
a998fe67de
commit
069fc674dc
708 files changed
+112482
-532
No files matched your search
@@ -0,0 +1,218 @@
|
||||
// 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
|
||||
}
|
||||
Reference in new issue
Block a user