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