Files

133 lines
4.6 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"
"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
}