// 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" "time" "git.stormux.org/storm/cthulhu/internal/atspi" ) // SetFlatOrigin starts a new flat-review context at the browse caret without // changing native focus. Once review begins, its cursor moves independently. func (worker *Worker) SetFlatOrigin(object atspi.ObjectID, offset int) { worker.stateMutex.Lock() worker.flatOrigin, worker.flatOffset = object, offset worker.stateMutex.Unlock() } func (worker *Worker) flatStart() (atspi.ObjectID, int) { worker.stateMutex.Lock() defer worker.stateMutex.Unlock() return worker.flatOrigin, worker.flatOffset } // FlatSpeech controls the speech of a review result without moving its cursor. type FlatSpeech uint8 const ( FlatSpeak FlatSpeech = iota FlatSpell FlatPhonetic ) // ReadFlat queues sequential review on the same bounded FIFO as object review. // Capacity is 16; overflow rejects the newest request and is reported to the // caller. Results retain FIFO order in a 16-result queue; a stalled consumer // blocks only this worker. Cancellation discards pending work and joins it. func (worker *Worker) ReadFlat(unit FlatUnit, step int) error { return worker.ReadFlatWithSpeech(unit, step, FlatSpeak) } // ReadFlatWithSpeech retains presentation policy with its FIFO request, so a // later press cannot change the speech of an earlier asynchronous result. func (worker *Worker) ReadFlatWithSpeech(unit FlatUnit, step int, style FlatSpeech) error { if unit < Line || unit > LineEnd || step < -1 || step > 1 || (unit > Character && step != 0) { return ErrInvalidDirection } if style > FlatPhonetic || (style != FlatSpeak && (step != 0 || (unit != Word && unit != Character))) { return ErrInvalidDirection } select { case worker.requests <- request{documentEpoch: worker.documentEpoch.Load(), unit: unit, step: step, flatSpeech: style}: return nil default: return ErrRequestQueueFull } } func (worker *Worker) readFlat(ctx context.Context, cursor *FlatCursor, tree *atspi.AccessibleTree, unit FlatUnit, step int) (Result, *atspi.AccessibleTree) { focus := worker.focus() result := Result{FlatUnit: unit, FlatStep: step, FlatFocus: focus} if !focus.Valid() { result.Err = ErrNoCurrent return result, tree } fresh := false if policy, ok := worker.capturer.(FreshReviewWindowCapturer); ok { fresh = policy.FreshReviewWindow(focus) } var err error if !fresh { err = cursor.Refresh(ctx, tree, focus) } if fresh || errors.Is(err, ErrNoCurrent) || errors.Is(err, atspi.ErrObjectNotFound) || errors.Is(err, atspi.ErrStaleObject) || errors.Is(err, atspi.ErrIncompleteTree) || errors.Is(err, atspi.ErrStaleText) || errors.Is(err, atspi.ErrStaleGeometry) { result.CaptureReason = err timeout := worker.flatCaptureTimeout if fresh { timeout = min(timeout, 500*time.Millisecond) } captureContext, cancel := context.WithTimeout(ctx, timeout) captureStarted := time.Now() captured, captureErr := worker.capturer.CaptureReviewWindow(captureContext, focus) result.CaptureDuration = time.Since(captureStarted) cancel() if captureErr != nil { result.Err = captureErr return result, tree } if captured == nil { result.Err = errors.New("capture flat review tree: nil tree") return result, tree } tree = captured // Capture can outlive a focus transition. Use the newest identity and // refresh only once: repeated invalidation cannot cause a retry loop. result.FlatFocus = worker.focus() err = cursor.Refresh(ctx, tree, result.FlatFocus) } if err != nil { result.Err = err return result, tree } if cursor.focus == focus && !cursor.started { if origin, offset := worker.flatStart(); origin.Valid() { if !cursor.seek(origin, offset, true) { if !cursor.seek(origin, 0, false) { cursor.seekDescendant(origin) } } } cursor.started = true } result.FlatPosition, result.Moved, result.Err = cursor.Read(unit, step) return result, tree }