Files

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() }