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,374 @@
|
||||
// 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 review
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
)
|
||||
|
||||
const (
|
||||
requestCapacity = 16
|
||||
resultCapacity = 16
|
||||
)
|
||||
|
||||
var ErrRequestQueueFull = errors.New("object review request queue is full")
|
||||
|
||||
// Worker owns the runtime review cursor and every potentially blocking tree
|
||||
// capture. Focus updates are coalesced to the newest identity. Navigation
|
||||
// requests remain ordered. After a document timeout, queued document commands
|
||||
// are retired so an unavailable provider cannot build a backlog of failures.
|
||||
type Worker struct {
|
||||
capturer TreeCapturer
|
||||
captureTimeout time.Duration
|
||||
flatCaptureTimeout time.Duration
|
||||
|
||||
stateMutex sync.Mutex
|
||||
latestFocus atspi.ObjectID
|
||||
focusRevision uint64
|
||||
flatOrigin atspi.ObjectID
|
||||
flatOffset int
|
||||
started bool
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
|
||||
wakeFocus chan struct{}
|
||||
requests chan request
|
||||
results chan Result
|
||||
lineSequence atomic.Uint64
|
||||
documentEpoch atomic.Uint64
|
||||
documentBraille atomic.Bool
|
||||
}
|
||||
|
||||
// SetDocumentBrailleEnabled avoids extra native line reads while braille is off.
|
||||
func (worker *Worker) SetDocumentBrailleEnabled(enabled bool) {
|
||||
worker.documentBraille.Store(enabled)
|
||||
}
|
||||
|
||||
// ResetDocument retires queued work and cursors for the previous page, even
|
||||
// when the provider reuses accessible paths after navigation.
|
||||
func (worker *Worker) ResetDocument(focus atspi.ObjectID) {
|
||||
worker.stateMutex.Lock()
|
||||
worker.latestFocus = focus
|
||||
worker.focusRevision++
|
||||
worker.flatOrigin = atspi.ObjectID{}
|
||||
worker.documentEpoch.Add(1)
|
||||
worker.stateMutex.Unlock()
|
||||
select {
|
||||
case worker.wakeFocus <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (worker *Worker) CurrentDocumentResult(result Result) bool {
|
||||
return result.DocumentEpoch == worker.documentEpoch.Load()
|
||||
}
|
||||
|
||||
func (worker *Worker) DocumentEpoch() uint64 { return worker.documentEpoch.Load() }
|
||||
|
||||
func NewWorker(capturer TreeCapturer, captureTimeout time.Duration) (*Worker, error) {
|
||||
if capturer == nil {
|
||||
return nil, errors.New("create object review worker: nil tree capturer")
|
||||
}
|
||||
if captureTimeout <= 0 {
|
||||
return nil, errors.New("create object review worker: capture timeout must be positive")
|
||||
}
|
||||
return &Worker{
|
||||
capturer: capturer,
|
||||
captureTimeout: captureTimeout,
|
||||
flatCaptureTimeout: captureTimeout,
|
||||
wakeFocus: make(chan struct{}, 1),
|
||||
requests: make(chan request, requestCapacity),
|
||||
results: make(chan Result, resultCapacity),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetFlatCaptureTimeout gives viewport review its own capture budget. Call it
|
||||
// before Start; ordinary object and document operations keep their deadline.
|
||||
func (worker *Worker) SetFlatCaptureTimeout(timeout time.Duration) error {
|
||||
if timeout <= 0 {
|
||||
return errors.New("flat review capture timeout must be positive")
|
||||
}
|
||||
worker.stateMutex.Lock()
|
||||
defer worker.stateMutex.Unlock()
|
||||
if worker.started {
|
||||
return errors.New("cannot change flat review capture timeout after start")
|
||||
}
|
||||
worker.flatCaptureTimeout = timeout
|
||||
return nil
|
||||
}
|
||||
|
||||
func (worker *Worker) Results() <-chan Result {
|
||||
return worker.results
|
||||
}
|
||||
|
||||
func (worker *Worker) Start(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return errors.New("start object review worker: nil context")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
worker.stateMutex.Lock()
|
||||
defer worker.stateMutex.Unlock()
|
||||
if worker.started {
|
||||
return errors.New("object review worker already started")
|
||||
}
|
||||
workerContext, cancel := context.WithCancel(ctx)
|
||||
worker.started = true
|
||||
worker.cancel = cancel
|
||||
worker.done = make(chan struct{})
|
||||
go worker.run(workerContext, worker.done)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (worker *Worker) Stop(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return errors.New("stop object review worker: nil context")
|
||||
}
|
||||
worker.stateMutex.Lock()
|
||||
cancel, done := worker.cancel, worker.done
|
||||
worker.stateMutex.Unlock()
|
||||
if done == nil {
|
||||
return nil
|
||||
}
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// SyncFocus records the newest focused identity without waiting for capture.
|
||||
// Invalid identities are ignored because some synthetic or shutdown events do
|
||||
// not identify a live AT-SPI object.
|
||||
func (worker *Worker) SyncFocus(focus atspi.ObjectID) {
|
||||
if !focus.Valid() {
|
||||
return
|
||||
}
|
||||
worker.stateMutex.Lock()
|
||||
if worker.latestFocus != focus {
|
||||
worker.focusRevision++
|
||||
worker.flatOrigin = atspi.ObjectID{}
|
||||
}
|
||||
worker.latestFocus = focus
|
||||
worker.stateMutex.Unlock()
|
||||
select {
|
||||
case worker.wakeFocus <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Recover a removed browse cursor only while it is still the current focus.
|
||||
func (worker *Worker) recoverDocumentFocus(oldFocus, newFocus atspi.ObjectID, epoch uint64) bool {
|
||||
worker.stateMutex.Lock()
|
||||
defer worker.stateMutex.Unlock()
|
||||
if worker.latestFocus != oldFocus || worker.documentEpoch.Load() != epoch {
|
||||
return false
|
||||
}
|
||||
worker.latestFocus = newFocus
|
||||
worker.focusRevision++
|
||||
return true
|
||||
}
|
||||
|
||||
// ClearFocus retires review ownership when the active window is deactivated
|
||||
// or destroyed. Unlike SyncFocus, an invalid identity is meaningful here.
|
||||
func (worker *Worker) ClearFocus() {
|
||||
worker.stateMutex.Lock()
|
||||
worker.latestFocus = atspi.ObjectID{}
|
||||
worker.focusRevision++
|
||||
worker.flatOrigin = atspi.ObjectID{}
|
||||
worker.documentEpoch.Add(1)
|
||||
worker.stateMutex.Unlock()
|
||||
select {
|
||||
case worker.wakeFocus <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Move queues a semantic navigation request without waiting for AT-SPI.
|
||||
func (worker *Worker) Move(direction Direction) error {
|
||||
if direction < Parent || direction > NextSibling {
|
||||
return fmt.Errorf("%w: %d", ErrInvalidDirection, direction)
|
||||
}
|
||||
select {
|
||||
case worker.requests <- request{direction: direction}:
|
||||
return nil
|
||||
default:
|
||||
return ErrRequestQueueFull
|
||||
}
|
||||
}
|
||||
|
||||
func (worker *Worker) run(ctx context.Context, done chan<- struct{}) {
|
||||
defer close(done)
|
||||
var cursor *Cursor
|
||||
var document *DocumentCursor
|
||||
var documentTextTree *atspi.AccessibleTree
|
||||
var documentEpoch uint64
|
||||
var failedDocumentBefore time.Time
|
||||
var interestFocus atspi.ObjectID
|
||||
var interestRevision uint64
|
||||
explicitInterest := implicitInterest
|
||||
flat := &FlatCursor{}
|
||||
var flatTree *atspi.AccessibleTree
|
||||
for {
|
||||
// A ready request/result channel can win a select even after Stop.
|
||||
// Do not drain queued work or start further captures during shutdown.
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-worker.wakeFocus:
|
||||
if next, err := worker.syncLatestFocus(ctx, cursor, false); err == nil {
|
||||
cursor = next
|
||||
}
|
||||
case request := <-worker.requests:
|
||||
worker.stateMutex.Lock()
|
||||
focus, revision := worker.latestFocus, worker.focusRevision
|
||||
worker.stateMutex.Unlock()
|
||||
if revision != interestRevision || focus != interestFocus {
|
||||
interestFocus, interestRevision, explicitInterest = focus, revision, implicitInterest
|
||||
}
|
||||
if epoch := worker.documentEpoch.Load(); epoch != documentEpoch {
|
||||
document, documentTextTree = nil, nil
|
||||
explicitInterest = implicitInterest
|
||||
flat, flatTree = &FlatCursor{}, nil
|
||||
documentEpoch = epoch
|
||||
failedDocumentBefore = time.Time{}
|
||||
}
|
||||
if request.interest != nil {
|
||||
interest, err := resolve_interest(request.interest.ctx, interestFocus, explicitInterest, cursor, flat, flatTree, document)
|
||||
worker.stateMutex.Lock()
|
||||
currentFocus, currentRevision := worker.latestFocus, worker.focusRevision
|
||||
worker.stateMutex.Unlock()
|
||||
if currentFocus != interestFocus || currentRevision != interestRevision {
|
||||
interest, err = Interest{}, ErrNoCurrent
|
||||
}
|
||||
if contextErr := request.interest.ctx.Err(); contextErr != nil {
|
||||
interest, err = Interest{}, contextErr
|
||||
}
|
||||
request.interest.result <- interestReply{interest, err}
|
||||
continue
|
||||
}
|
||||
if (request.brailleRoute != nil || request.nativeCaret || request.readAll != nil || request.line || request.target != 0 || request.caretCommand != 0 || request.activate || request.fragment != "" || request.unit != 0) && request.documentEpoch != documentEpoch {
|
||||
if request.brailleRoute != nil {
|
||||
request.brailleRoute.done <- atspi.ErrStaleObject
|
||||
}
|
||||
continue
|
||||
}
|
||||
documentCommand := request.target != 0 || request.caretCommand != 0 || request.activate || request.fragment != ""
|
||||
if documentCommand && request.queuedAt.Before(failedDocumentBefore) {
|
||||
continue
|
||||
}
|
||||
var result Result
|
||||
if request.brailleRoute != nil {
|
||||
result = worker.route_document_braille(ctx, document, request)
|
||||
} else if request.nativeCaret {
|
||||
result, document = worker.syncDocumentCaret(ctx, document, request)
|
||||
} else if request.readPosition != nil {
|
||||
if request.documentEpoch != documentEpoch || request.readFocus != worker.focus() {
|
||||
continue
|
||||
}
|
||||
var followed bool
|
||||
document, followed = worker.followReadAll(ctx, document, request)
|
||||
if !followed {
|
||||
continue
|
||||
}
|
||||
result = Result{ReadAllPosition: true, DocumentFocus: request.readFocus, DocumentPosition: *request.readPosition}
|
||||
} else if request.readAll != nil {
|
||||
readContext, cancel := context.WithCancel(ctx)
|
||||
stop := context.AfterFunc(request.readAll, cancel)
|
||||
result = worker.readAll(readContext, document)
|
||||
stop()
|
||||
cancel()
|
||||
result.ReadAllContext = request.readAll
|
||||
} else if request.line {
|
||||
result, documentTextTree = worker.readDocumentLine(ctx, documentTextTree, request.lineRequest, request.lineFocus, request.lineOffset)
|
||||
result.Elapsed = time.Since(request.queuedAt)
|
||||
} else if request.target != 0 {
|
||||
result, document = worker.moveDocument(ctx, document, request.target, request.level, request.backwards, request.documentEpoch)
|
||||
result.Elapsed = time.Since(request.queuedAt)
|
||||
} else if request.caretCommand != 0 {
|
||||
result, document = worker.moveDocumentCaret(ctx, document, request.caretCommand, request.documentEpoch)
|
||||
result.Elapsed = time.Since(request.queuedAt)
|
||||
} else if request.activate {
|
||||
result, document = worker.activateDocumentCaret(ctx, document, request.documentEpoch)
|
||||
result.Elapsed = time.Since(request.queuedAt)
|
||||
} else if request.fragment != "" {
|
||||
result, document = worker.jumpDocumentFragment(ctx, document, request.fragment, request.documentEpoch)
|
||||
result.Elapsed = time.Since(request.queuedAt)
|
||||
} else if request.unit != 0 {
|
||||
result, flatTree = worker.readFlat(ctx, flat, flatTree, request.unit, request.step)
|
||||
result.FlatSpeech = request.flatSpeech
|
||||
} else {
|
||||
movement := worker.move(ctx, cursor, request.direction)
|
||||
cursor = movement.cursor
|
||||
result = movement.Result
|
||||
}
|
||||
if result.Err == nil {
|
||||
if request.unit != 0 {
|
||||
explicitInterest = flatInterest
|
||||
} else if request.direction != 0 {
|
||||
explicitInterest = objectInterest
|
||||
} else if request.brailleRoute != nil || request.target != 0 || request.caretCommand != 0 || request.nativeCaret || request.fragment != "" || request.readPosition != nil {
|
||||
explicitInterest = implicitInterest
|
||||
}
|
||||
worker.stateMutex.Lock()
|
||||
if worker.latestFocus != interestFocus || worker.focusRevision != interestRevision {
|
||||
explicitInterest = implicitInterest
|
||||
}
|
||||
interestFocus, interestRevision = worker.latestFocus, worker.focusRevision
|
||||
worker.stateMutex.Unlock()
|
||||
}
|
||||
result.DocumentEpoch = request.documentEpoch
|
||||
if documentCommand && worker.CurrentDocumentResult(result) && errors.Is(result.Err, context.DeadlineExceeded) {
|
||||
// Keys pressed while the provider was unavailable must not each
|
||||
// start another full timeout. A command issued after this failure
|
||||
// can try again; successful navigation keeps its ordered steps.
|
||||
failedDocumentBefore = time.Now()
|
||||
}
|
||||
if request.brailleRoute != nil || request.target != 0 || request.caretCommand != 0 || request.fragment != "" {
|
||||
result = worker.readDocumentBraille(ctx, result)
|
||||
result.Elapsed = time.Since(request.queuedAt)
|
||||
}
|
||||
select {
|
||||
case worker.results <- result:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (worker *Worker) focus() atspi.ObjectID {
|
||||
worker.stateMutex.Lock()
|
||||
defer worker.stateMutex.Unlock()
|
||||
return worker.latestFocus
|
||||
}
|
||||
|
||||
func (worker *Worker) Focus() atspi.ObjectID { return worker.focus() }
|
||||
Reference in new issue
Block a user