375 lines
13 KiB
Go
375 lines
13 KiB
Go
// 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() }
|